Initial commit: Open-sourcing VoiceInk
100
.gitignore
vendored
Normal file
@ -0,0 +1,100 @@
|
||||
# Xcode
|
||||
#
|
||||
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## Obj-C/Swift specific
|
||||
*.hmap
|
||||
|
||||
## App packaging
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
## Playgrounds
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
# Swift Package Manager
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
|
||||
# Packages/
|
||||
# Package.pins
|
||||
# Package.resolved
|
||||
# *.xcodeproj
|
||||
#
|
||||
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
|
||||
# hence it is not needed unless you have added a package configuration file to your project
|
||||
# .swiftpm
|
||||
|
||||
.build/
|
||||
|
||||
# CocoaPods
|
||||
#
|
||||
# We recommend against adding the Pods directory to your .gitignore. However
|
||||
# you should judge for yourself, the pros and cons are mentioned at:
|
||||
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
|
||||
#
|
||||
# Pods/
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from the Xcode workspace
|
||||
# *.xcworkspace
|
||||
|
||||
# Carthage
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Carthage dependencies.
|
||||
# Carthage/Checkouts
|
||||
|
||||
Carthage/Build/
|
||||
|
||||
# Accio dependency management
|
||||
Dependencies/
|
||||
.accio/
|
||||
|
||||
# fastlane
|
||||
#
|
||||
# It is recommended to not store the screenshots in the git repo.
|
||||
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
|
||||
# For more information about the recommended setup visit:
|
||||
# https://docs.fastlane.tools/best-practices/source-control/#source-control
|
||||
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
|
||||
# Code Injection
|
||||
#
|
||||
# After new code Injection tools there's a generated folder /iOSInjectionProject
|
||||
# https://github.com/johnno1962/injectionforxcode
|
||||
|
||||
iOSInjectionProject/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
63
CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
85
CONTRIBUTING.md
Normal file
@ -0,0 +1,85 @@
|
||||
# Contributing to VoiceInk
|
||||
|
||||
First off, thank you for considering contributing to VoiceInk! It's people like you that make VoiceInk such a great tool.
|
||||
|
||||
## Important Notice
|
||||
|
||||
Before starting work on any new feature or fix, please reach out to us first by opening an issue or discussion. This is crucial because:
|
||||
|
||||
1. We want to ensure your contribution aligns with the project's goals and vision
|
||||
2. Someone else might already be working on something similar
|
||||
3. We might have valuable insights or requirements that could save you time
|
||||
4. Your proposed changes might need some adjustments to fit with our roadmap
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
- Before submitting a bug report, please check if the issue has already been reported
|
||||
- Use the bug report template when creating an issue
|
||||
- Include as much relevant information as possible
|
||||
- Include steps to reproduce the issue
|
||||
|
||||
### Suggesting Enhancements
|
||||
|
||||
- Open an issue using the feature request template
|
||||
- Clearly describe the feature and its benefits
|
||||
- Discuss potential implementation approaches
|
||||
- Consider the feature's impact on existing functionality
|
||||
|
||||
### Pull Requests
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a new branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Make your changes
|
||||
4. Run tests and ensure they pass
|
||||
5. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
6. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
7. Open a Pull Request
|
||||
|
||||
### Development Process
|
||||
|
||||
1. Ensure you have all the requirements installed:
|
||||
- macOS 14.0 or later
|
||||
- Latest version of Xcode
|
||||
- Latest version of Swift
|
||||
- whisper.cpp properly set up
|
||||
|
||||
2. Follow our coding standards:
|
||||
- Use Swift style guidelines
|
||||
- Write meaningful commit messages
|
||||
- Include comments where necessary
|
||||
- Add tests for new features
|
||||
|
||||
3. Testing:
|
||||
- Run existing tests
|
||||
- Add new tests for new functionality
|
||||
- Ensure all tests pass before submitting PR
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
- Follow Swift style guidelines
|
||||
- Use meaningful variable and function names
|
||||
- Keep functions focused and concise
|
||||
- Comment complex logic
|
||||
- Write self-documenting code where possible
|
||||
|
||||
## Community
|
||||
|
||||
- Join our discussions
|
||||
- Help other contributors
|
||||
- Share your ideas
|
||||
- Be respectful and constructive
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have any questions or need clarification, feel free to:
|
||||
1. Open an issue
|
||||
2. Start a discussion
|
||||
3. Reach out to the maintainers
|
||||
|
||||
Thank you for contributing to VoiceInk! 🎉
|
||||
29
LICENSE
Normal file
@ -0,0 +1,29 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
[For the complete license text, please visit: https://www.gnu.org/licenses/gpl-3.0.txt]
|
||||
65
README.md
Normal file
@ -0,0 +1,65 @@
|
||||
# VoiceInk
|
||||
|
||||
VoiceInk is a powerful macOS application that transforms your voice into text in real-time, providing a seamless and efficient way to capture your thoughts, create content, and transcribe audio.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎙️ Real-time voice transcription
|
||||
- 💻 Native macOS application
|
||||
- 🚀 Powered by whisper.cpp for efficient, local transcription
|
||||
- 🔒 Privacy-focused: All processing happens locally on your device
|
||||
- ⚡ Fast and responsive interface
|
||||
- 📝 Easy-to-use text editor
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 14.0 or later
|
||||
- Xcode (latest version recommended)
|
||||
- Swift (latest version recommended)
|
||||
- whisper.cpp
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository
|
||||
```bash
|
||||
git clone https://github.com/yourusername/VoiceInk.git
|
||||
cd VoiceInk
|
||||
```
|
||||
|
||||
2. Open the project in Xcode
|
||||
```bash
|
||||
open VoiceInk.xcodeproj
|
||||
```
|
||||
|
||||
3. Build and run the project
|
||||
|
||||
## Building from Source
|
||||
|
||||
1. Ensure you have all the requirements installed
|
||||
2. Open the project in Xcode
|
||||
3. Build the project using Cmd+B or Product > Build
|
||||
4. Run the project using Cmd+R or Product > Run
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Please read our [CONTRIBUTING.md](CONTRIBUTING.md) guide before submitting any changes.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter any issues or have questions, please:
|
||||
1. Check the existing issues in the GitHub repository
|
||||
2. Create a new issue if your problem isn't already reported
|
||||
3. Provide as much detail as possible about your environment and the problem
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [whisper.cpp](https://github.com/ggerganov/whisper.cpp) for providing the core transcription capabilities
|
||||
- All contributors who help make this project better
|
||||
|
||||
---
|
||||
|
||||
Made with ❤️ by the VoiceInk team
|
||||
686
VoiceInk.xcodeproj/project.pbxproj
Normal file
@ -0,0 +1,686 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
E19E53B02D36D8120067F3D4 /* libggml.dylib in CopyFiles */ = {isa = PBXBuildFile; fileRef = E19E53A72D36D7EF0067F3D4 /* libggml.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
E19E53B12D36D8200067F3D4 /* libggml-base.dylib in CopyFiles */ = {isa = PBXBuildFile; fileRef = E19E53A82D36D7EF0067F3D4 /* libggml-base.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
E19E53B22D36D82F0067F3D4 /* libwhisper.1.dylib in CopyFiles */ = {isa = PBXBuildFile; fileRef = E19E53AD2D36D7EF0067F3D4 /* libwhisper.1.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
E19E53B32D36D84A0067F3D4 /* libggml-cpu.dylib in CopyFiles */ = {isa = PBXBuildFile; fileRef = E19E53AA2D36D7EF0067F3D4 /* libggml-cpu.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
E19E53B42D36D85B0067F3D4 /* libggml-blas.dylib in CopyFiles */ = {isa = PBXBuildFile; fileRef = E19E53A92D36D7EF0067F3D4 /* libggml-blas.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
E19E53B52D36D8660067F3D4 /* libggml-metal.dylib in CopyFiles */ = {isa = PBXBuildFile; fileRef = E19E53AB2D36D7EF0067F3D4 /* libggml-metal.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = E1A261112CC143AC00B233D1 /* KeyboardShortcuts */; };
|
||||
E1ADD45A2CC5352A00303ECB /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD4592CC5352A00303ECB /* LaunchAtLogin */; };
|
||||
E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD45E2CC544F100303ECB /* Sparkle */; };
|
||||
E1E0F4262CC7FF300005EE87 /* whisper in Frameworks */ = {isa = PBXBuildFile; productRef = E1E0F4252CC7FF300005EE87 /* whisper */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
E11473C42CBE0F0B00318EE4 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = E11473A82CBE0F0A00318EE4 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = E11473AF2CBE0F0A00318EE4;
|
||||
remoteInfo = VoiceInk;
|
||||
};
|
||||
E11473CE2CBE0F0B00318EE4 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = E11473A82CBE0F0A00318EE4 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = E11473AF2CBE0F0A00318EE4;
|
||||
remoteInfo = VoiceInk;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
E19E53AF2D36D80A0067F3D4 /* CopyFiles */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
E19E53B52D36D8660067F3D4 /* libggml-metal.dylib in CopyFiles */,
|
||||
E19E53B42D36D85B0067F3D4 /* libggml-blas.dylib in CopyFiles */,
|
||||
E19E53B32D36D84A0067F3D4 /* libggml-cpu.dylib in CopyFiles */,
|
||||
E19E53B22D36D82F0067F3D4 /* libwhisper.1.dylib in CopyFiles */,
|
||||
E19E53B12D36D8200067F3D4 /* libggml-base.dylib in CopyFiles */,
|
||||
E19E53B02D36D8120067F3D4 /* libggml.dylib in CopyFiles */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
E11473B02CBE0F0A00318EE4 /* VoiceInk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceInk.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E11473C32CBE0F0B00318EE4 /* VoiceInkTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VoiceInkTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E11473CD2CBE0F0B00318EE4 /* VoiceInkUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VoiceInkUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E19E53A72D36D7EF0067F3D4 /* libggml.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libggml.dylib; path = /usr/local/lib/libggml.dylib; sourceTree = "<absolute>"; };
|
||||
E19E53A82D36D7EF0067F3D4 /* libggml-base.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = "libggml-base.dylib"; path = "/usr/local/lib/libggml-base.dylib"; sourceTree = "<absolute>"; };
|
||||
E19E53A92D36D7EF0067F3D4 /* libggml-blas.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = "libggml-blas.dylib"; path = "/usr/local/lib/libggml-blas.dylib"; sourceTree = "<absolute>"; };
|
||||
E19E53AA2D36D7EF0067F3D4 /* libggml-cpu.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = "libggml-cpu.dylib"; path = "/usr/local/lib/libggml-cpu.dylib"; sourceTree = "<absolute>"; };
|
||||
E19E53AB2D36D7EF0067F3D4 /* libggml-metal.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = "libggml-metal.dylib"; path = "/usr/local/lib/libggml-metal.dylib"; sourceTree = "<absolute>"; };
|
||||
E19E53AC2D36D7EF0067F3D4 /* libwhisper.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libwhisper.dylib; path = /usr/local/lib/libwhisper.dylib; sourceTree = "<absolute>"; };
|
||||
E19E53AD2D36D7EF0067F3D4 /* libwhisper.1.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libwhisper.1.dylib; path = /usr/local/lib/libwhisper.1.dylib; sourceTree = "<absolute>"; };
|
||||
E19E53AE2D36D7EF0067F3D4 /* libwhisper.1.7.4.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libwhisper.1.7.4.dylib; path = /usr/local/lib/libwhisper.1.7.4.dylib; sourceTree = "<absolute>"; };
|
||||
E1E0F4242CC7FF2A0005EE87 /* whisper.cpp */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = whisper.cpp; path = /Users/beingpax/whisper.cpp; sourceTree = "<absolute>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
E11473B22CBE0F0A00318EE4 /* VoiceInk */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = VoiceInk;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E11473C62CBE0F0B00318EE4 /* VoiceInkTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = VoiceInkTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E11473D02CBE0F0B00318EE4 /* VoiceInkUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = VoiceInkUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
E11473AD2CBE0F0A00318EE4 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E1ADD45A2CC5352A00303ECB /* LaunchAtLogin in Frameworks */,
|
||||
E1E0F4262CC7FF300005EE87 /* whisper in Frameworks */,
|
||||
E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */,
|
||||
E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E11473C02CBE0F0B00318EE4 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E11473CA2CBE0F0B00318EE4 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
E11473A72CBE0F0A00318EE4 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E11473B22CBE0F0A00318EE4 /* VoiceInk */,
|
||||
E11473C62CBE0F0B00318EE4 /* VoiceInkTests */,
|
||||
E11473D02CBE0F0B00318EE4 /* VoiceInkUITests */,
|
||||
E114741C2CBE1DE200318EE4 /* Frameworks */,
|
||||
E11473B12CBE0F0A00318EE4 /* Products */,
|
||||
E1E0F4242CC7FF2A0005EE87 /* whisper.cpp */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E11473B12CBE0F0A00318EE4 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E11473B02CBE0F0A00318EE4 /* VoiceInk.app */,
|
||||
E11473C32CBE0F0B00318EE4 /* VoiceInkTests.xctest */,
|
||||
E11473CD2CBE0F0B00318EE4 /* VoiceInkUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E114741C2CBE1DE200318EE4 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E19E53A72D36D7EF0067F3D4 /* libggml.dylib */,
|
||||
E19E53A82D36D7EF0067F3D4 /* libggml-base.dylib */,
|
||||
E19E53A92D36D7EF0067F3D4 /* libggml-blas.dylib */,
|
||||
E19E53AA2D36D7EF0067F3D4 /* libggml-cpu.dylib */,
|
||||
E19E53AB2D36D7EF0067F3D4 /* libggml-metal.dylib */,
|
||||
E19E53AC2D36D7EF0067F3D4 /* libwhisper.dylib */,
|
||||
E19E53AD2D36D7EF0067F3D4 /* libwhisper.1.dylib */,
|
||||
E19E53AE2D36D7EF0067F3D4 /* libwhisper.1.7.4.dylib */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
E11473AF2CBE0F0A00318EE4 /* VoiceInk */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E11473D72CBE0F0B00318EE4 /* Build configuration list for PBXNativeTarget "VoiceInk" */;
|
||||
buildPhases = (
|
||||
E11473AC2CBE0F0A00318EE4 /* Sources */,
|
||||
E11473AD2CBE0F0A00318EE4 /* Frameworks */,
|
||||
E11473AE2CBE0F0A00318EE4 /* Resources */,
|
||||
E19E53AF2D36D80A0067F3D4 /* CopyFiles */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
E11473B22CBE0F0A00318EE4 /* VoiceInk */,
|
||||
);
|
||||
name = VoiceInk;
|
||||
packageProductDependencies = (
|
||||
E1A261112CC143AC00B233D1 /* KeyboardShortcuts */,
|
||||
E1ADD4592CC5352A00303ECB /* LaunchAtLogin */,
|
||||
E1ADD45E2CC544F100303ECB /* Sparkle */,
|
||||
E1E0F4252CC7FF300005EE87 /* whisper */,
|
||||
);
|
||||
productName = VoiceInk;
|
||||
productReference = E11473B02CBE0F0A00318EE4 /* VoiceInk.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
E11473C22CBE0F0B00318EE4 /* VoiceInkTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E11473DA2CBE0F0B00318EE4 /* Build configuration list for PBXNativeTarget "VoiceInkTests" */;
|
||||
buildPhases = (
|
||||
E11473BF2CBE0F0B00318EE4 /* Sources */,
|
||||
E11473C02CBE0F0B00318EE4 /* Frameworks */,
|
||||
E11473C12CBE0F0B00318EE4 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
E11473C52CBE0F0B00318EE4 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
E11473C62CBE0F0B00318EE4 /* VoiceInkTests */,
|
||||
);
|
||||
name = VoiceInkTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = VoiceInkTests;
|
||||
productReference = E11473C32CBE0F0B00318EE4 /* VoiceInkTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
E11473CC2CBE0F0B00318EE4 /* VoiceInkUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E11473DD2CBE0F0B00318EE4 /* Build configuration list for PBXNativeTarget "VoiceInkUITests" */;
|
||||
buildPhases = (
|
||||
E11473C92CBE0F0B00318EE4 /* Sources */,
|
||||
E11473CA2CBE0F0B00318EE4 /* Frameworks */,
|
||||
E11473CB2CBE0F0B00318EE4 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
E11473CF2CBE0F0B00318EE4 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
E11473D02CBE0F0B00318EE4 /* VoiceInkUITests */,
|
||||
);
|
||||
name = VoiceInkUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = VoiceInkUITests;
|
||||
productReference = E11473CD2CBE0F0B00318EE4 /* VoiceInkUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
E11473A82CBE0F0A00318EE4 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1600;
|
||||
LastUpgradeCheck = 1600;
|
||||
TargetAttributes = {
|
||||
E11473AF2CBE0F0A00318EE4 = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
};
|
||||
E11473C22CBE0F0B00318EE4 = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
TestTargetID = E11473AF2CBE0F0A00318EE4;
|
||||
};
|
||||
E11473CC2CBE0F0B00318EE4 = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
TestTargetID = E11473AF2CBE0F0A00318EE4;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = E11473AB2CBE0F0A00318EE4 /* Build configuration list for PBXProject "VoiceInk" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = E11473A72CBE0F0A00318EE4;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
E1A261102CC143AC00B233D1 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */,
|
||||
E1ADD4582CC5352A00303ECB /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */,
|
||||
E1ADD45D2CC544F100303ECB /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = E11473B12CBE0F0A00318EE4 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
E11473AF2CBE0F0A00318EE4 /* VoiceInk */,
|
||||
E11473C22CBE0F0B00318EE4 /* VoiceInkTests */,
|
||||
E11473CC2CBE0F0B00318EE4 /* VoiceInkUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
E11473AE2CBE0F0A00318EE4 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E11473C12CBE0F0B00318EE4 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E11473CB2CBE0F0B00318EE4 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
E11473AC2CBE0F0A00318EE4 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E11473BF2CBE0F0B00318EE4 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E11473C92CBE0F0B00318EE4 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
E11473C52CBE0F0B00318EE4 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = E11473AF2CBE0F0A00318EE4 /* VoiceInk */;
|
||||
targetProxy = E11473C42CBE0F0B00318EE4 /* PBXContainerItemProxy */;
|
||||
};
|
||||
E11473CF2CBE0F0B00318EE4 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = E11473AF2CBE0F0A00318EE4 /* VoiceInk */;
|
||||
targetProxy = E11473CE2CBE0F0B00318EE4 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
E11473D52CBE0F0B00318EE4 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
E11473D62CBE0F0B00318EE4 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
E11473D82CBE0F0B00318EE4 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ARCHS = arm64;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceInk/VoiceInk.entitlements;
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 91;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"VoiceInk/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = V6J6A3VWY2;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = VoiceInk/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = VoiceInk;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.91;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.prakashjoshipax.VoiceInk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
E11473D92CBE0F0B00318EE4 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ARCHS = arm64;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceInk/VoiceInk.entitlements;
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 91;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"VoiceInk/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = V6J6A3VWY2;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = VoiceInk/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = VoiceInk;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.91;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.prakashjoshipax.VoiceInk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
E11473DB2CBE0F0B00318EE4 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V6J6A3VWY2;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.prakashjoshipax.VoiceInkTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VoiceInk.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VoiceInk";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
E11473DC2CBE0F0B00318EE4 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V6J6A3VWY2;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.prakashjoshipax.VoiceInkTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VoiceInk.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VoiceInk";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
E11473DE2CBE0F0B00318EE4 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V6J6A3VWY2;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.prakashjoshipax.VoiceInkUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_TARGET_NAME = VoiceInk;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
E11473DF2CBE0F0B00318EE4 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V6J6A3VWY2;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.prakashjoshipax.VoiceInkUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_TARGET_NAME = VoiceInk;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
E11473AB2CBE0F0A00318EE4 /* Build configuration list for PBXProject "VoiceInk" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
E11473D52CBE0F0B00318EE4 /* Debug */,
|
||||
E11473D62CBE0F0B00318EE4 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
E11473D72CBE0F0B00318EE4 /* Build configuration list for PBXNativeTarget "VoiceInk" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
E11473D82CBE0F0B00318EE4 /* Debug */,
|
||||
E11473D92CBE0F0B00318EE4 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
E11473DA2CBE0F0B00318EE4 /* Build configuration list for PBXNativeTarget "VoiceInkTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
E11473DB2CBE0F0B00318EE4 /* Debug */,
|
||||
E11473DC2CBE0F0B00318EE4 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
E11473DD2CBE0F0B00318EE4 /* Build configuration list for PBXNativeTarget "VoiceInkUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
E11473DE2CBE0F0B00318EE4 /* Debug */,
|
||||
E11473DF2CBE0F0B00318EE4 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
E1A261102CC143AC00B233D1 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sindresorhus/KeyboardShortcuts";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.2.0;
|
||||
};
|
||||
};
|
||||
E1ADD4582CC5352A00303ECB /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sindresorhus/LaunchAtLogin-Modern";
|
||||
requirement = {
|
||||
branch = main;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
E1ADD45D2CC544F100303ECB /* XCRemoteSwiftPackageReference "Sparkle" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sparkle-project/Sparkle";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.6.4;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
E1A261112CC143AC00B233D1 /* KeyboardShortcuts */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = E1A261102CC143AC00B233D1 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */;
|
||||
productName = KeyboardShortcuts;
|
||||
};
|
||||
E1ADD4592CC5352A00303ECB /* LaunchAtLogin */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = E1ADD4582CC5352A00303ECB /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */;
|
||||
productName = LaunchAtLogin;
|
||||
};
|
||||
E1ADD45E2CC544F100303ECB /* Sparkle */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = E1ADD45D2CC544F100303ECB /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||
productName = Sparkle;
|
||||
};
|
||||
E1E0F4252CC7FF300005EE87 /* whisper */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = whisper;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = E11473A82CBE0F0A00318EE4 /* Project object */;
|
||||
}
|
||||
7
VoiceInk.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@ -0,0 +1,34 @@
|
||||
{
|
||||
"object": {
|
||||
"pins": [
|
||||
{
|
||||
"package": "KeyboardShortcuts",
|
||||
"repositoryURL": "https://github.com/sindresorhus/KeyboardShortcuts",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "7ecc38bb6edf7d087d30e737057b8d8a9b7f51eb",
|
||||
"version": "2.2.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "LaunchAtLogin",
|
||||
"repositoryURL": "https://github.com/sindresorhus/LaunchAtLogin-Modern",
|
||||
"state": {
|
||||
"branch": "main",
|
||||
"revision": "a04ec1c363be3627734f6dad757d82f5d4fa8fcc",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Sparkle",
|
||||
"repositoryURL": "https://github.com/sparkle-project/Sparkle",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "0ef1ee0220239b3776f433314515fd849025673f",
|
||||
"version": "2.6.4"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
39
VoiceInk/AppDelegate.swift
Normal file
@ -0,0 +1,39 @@
|
||||
import Cocoa
|
||||
import SwiftUI
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
updateActivationPolicy()
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
if !flag {
|
||||
createMainWindowIfNeeded()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
private func updateActivationPolicy() {
|
||||
let isMenuBarOnly = UserDefaults.standard.bool(forKey: "IsMenuBarOnly")
|
||||
if isMenuBarOnly {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
} else {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
}
|
||||
}
|
||||
|
||||
private func createMainWindowIfNeeded() {
|
||||
if NSApp.windows.isEmpty {
|
||||
let contentView = ContentView()
|
||||
let hostingView = NSHostingView(rootView: contentView)
|
||||
let window = WindowManager.shared.createMainWindow(contentView: hostingView)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
} else {
|
||||
NSApp.windows.first?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
VoiceInk/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
VoiceInk/Assets.xcassets/AppIcon.appiconset/1024-mac.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
VoiceInk/Assets.xcassets/AppIcon.appiconset/128-mac.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
VoiceInk/Assets.xcassets/AppIcon.appiconset/16-mac.png
Normal file
|
After Width: | Height: | Size: 300 B |
BIN
VoiceInk/Assets.xcassets/AppIcon.appiconset/256-mac.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
VoiceInk/Assets.xcassets/AppIcon.appiconset/32-mac.png
Normal file
|
After Width: | Height: | Size: 726 B |
BIN
VoiceInk/Assets.xcassets/AppIcon.appiconset/512-mac.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
VoiceInk/Assets.xcassets/AppIcon.appiconset/64-mac.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1 @@
|
||||
{"images":[{"size":"1024x1024","filename":"1024-mac.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"128x128","expected-size":"128","filename":"128-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"256x256","expected-size":"256","filename":"256-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"128x128","expected-size":"256","filename":"256-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"256x256","expected-size":"512","filename":"512-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"32","filename":"32-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"512x512","expected-size":"512","filename":"512-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"16","filename":"16-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"32","filename":"32-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"64","filename":"64-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"512x512","expected-size":"1024","filename":"1024-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"}]}
|
||||
6
VoiceInk/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
15
VoiceInk/Assets.xcassets/menuBarIcon.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Frame 1.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
VoiceInk/Assets.xcassets/menuBarIcon.imageset/Frame 1.png
vendored
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
236
VoiceInk/AudioEngine.swift
Normal file
@ -0,0 +1,236 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import CoreAudio
|
||||
import os
|
||||
|
||||
class AudioEngine: ObservableObject {
|
||||
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "AudioEngine")
|
||||
private lazy var engine = AVAudioEngine()
|
||||
private lazy var mixer = AVAudioMixerNode()
|
||||
@Published var isRunning = false
|
||||
@Published var audioLevel: CGFloat = 0.0
|
||||
|
||||
private var lastUpdateTime: TimeInterval = 0
|
||||
private var inputTap: Any?
|
||||
private let updateInterval: TimeInterval = 0.05
|
||||
private let deviceManager = AudioDeviceManager.shared
|
||||
private var deviceObserver: NSObjectProtocol?
|
||||
private var isConfiguring = false
|
||||
|
||||
init() {
|
||||
setupDeviceChangeObserver()
|
||||
}
|
||||
|
||||
private func setupDeviceChangeObserver() {
|
||||
deviceObserver = AudioDeviceConfiguration.createDeviceChangeObserver { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.isRunning {
|
||||
self.handleDeviceChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDeviceChange() {
|
||||
guard !isConfiguring else {
|
||||
logger.warning("Device change already in progress, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
isConfiguring = true
|
||||
logger.info("Handling device change - Current engine state: \(self.isRunning ? "Running" : "Stopped")")
|
||||
|
||||
// Stop the engine first
|
||||
stopAudioEngine()
|
||||
|
||||
// Log device change details
|
||||
let currentDeviceID = deviceManager.getCurrentDevice()
|
||||
if let deviceName = deviceManager.getDeviceName(deviceID: currentDeviceID) {
|
||||
logger.info("Switching to device: \(deviceName) (ID: \(currentDeviceID))")
|
||||
}
|
||||
|
||||
// Wait a bit for the system to process the device change
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Try to start with new device
|
||||
self.startAudioEngine()
|
||||
self.isConfiguring = false
|
||||
logger.info("Device change handling completed")
|
||||
}
|
||||
}
|
||||
|
||||
private func setupAudioEngine() {
|
||||
guard inputTap == nil else { return }
|
||||
|
||||
let bus = 0
|
||||
|
||||
// Get the current device (either selected or fallback)
|
||||
let currentDeviceID = deviceManager.getCurrentDevice()
|
||||
|
||||
if currentDeviceID != 0 {
|
||||
do {
|
||||
logger.info("Setting up audio engine with device ID: \(currentDeviceID)")
|
||||
// Log the device type (helps identify Bluetooth devices)
|
||||
if let deviceName = deviceManager.getDeviceName(deviceID: currentDeviceID) {
|
||||
let isBluetoothDevice = deviceName.lowercased().contains("bluetooth")
|
||||
logger.info("Device type: \(isBluetoothDevice ? "Bluetooth" : "Standard") - \(deviceName)")
|
||||
}
|
||||
|
||||
try configureAudioSession(with: currentDeviceID)
|
||||
} catch {
|
||||
logger.error("Audio engine setup failed: \(error.localizedDescription)")
|
||||
logger.error("Device ID: \(currentDeviceID)")
|
||||
if let deviceName = deviceManager.getDeviceName(deviceID: currentDeviceID) {
|
||||
logger.error("Failed device name: \(deviceName)")
|
||||
}
|
||||
// Don't return here, let it try with default device
|
||||
}
|
||||
} else {
|
||||
logger.info("No specific device available, using system default")
|
||||
}
|
||||
|
||||
// Wait briefly for device configuration to take effect
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
|
||||
// Log input format details
|
||||
let inputFormat = engine.inputNode.inputFormat(forBus: bus)
|
||||
logger.info("""
|
||||
Input format details:
|
||||
- Sample Rate: \(inputFormat.sampleRate)
|
||||
- Channel Count: \(inputFormat.channelCount)
|
||||
- Common Format: \(inputFormat.commonFormat.rawValue)
|
||||
|
||||
- Channel Layout: \(inputFormat.channelLayout?.layoutTag ?? 0)
|
||||
""")
|
||||
|
||||
inputTap = engine.inputNode.installTap(onBus: bus, bufferSize: 1024, format: inputFormat) { [weak self] (buffer, time) in
|
||||
self?.processAudioBuffer(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureAudioSession(with deviceID: AudioDeviceID) throws {
|
||||
logger.info("Starting audio session configuration for device ID: \(deviceID)")
|
||||
// Get the audio format from the selected device
|
||||
let streamFormat = try AudioDeviceConfiguration.configureAudioSession(with: deviceID)
|
||||
logger.info("Got stream format: \(streamFormat.mSampleRate)Hz, \(streamFormat.mChannelsPerFrame) channels")
|
||||
|
||||
// Configure the input node to use the selected device
|
||||
let inputNode = engine.inputNode
|
||||
guard let audioUnit = inputNode.audioUnit else {
|
||||
logger.error("Failed to get audio unit from input node")
|
||||
throw AudioConfigurationError.failedToGetAudioUnit
|
||||
}
|
||||
logger.info("Got audio unit from input node")
|
||||
|
||||
// Set the device for the audio unit
|
||||
try AudioDeviceConfiguration.configureAudioUnit(audioUnit, with: deviceID)
|
||||
logger.info("Configured audio unit with device")
|
||||
|
||||
// Reset the engine to apply the new configuration
|
||||
engine.stop()
|
||||
try engine.reset()
|
||||
logger.info("Reset audio engine")
|
||||
|
||||
// Use async dispatch instead of thread sleep
|
||||
DispatchQueue.global().async {
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
self.logger.info("Audio configuration delay completed")
|
||||
}
|
||||
}
|
||||
|
||||
func startAudioEngine() {
|
||||
guard !isRunning else { return }
|
||||
|
||||
logger.info("Starting audio engine")
|
||||
|
||||
do {
|
||||
setupAudioEngine()
|
||||
logger.info("Audio engine setup completed")
|
||||
|
||||
try engine.prepare()
|
||||
logger.info("Audio engine prepared")
|
||||
|
||||
try engine.start()
|
||||
isRunning = true
|
||||
|
||||
// Log active device and configuration details
|
||||
let currentDeviceID = deviceManager.getCurrentDevice()
|
||||
if let deviceName = deviceManager.getDeviceName(deviceID: currentDeviceID) {
|
||||
let isBluetoothDevice = deviceName.lowercased().contains("bluetooth")
|
||||
logger.info("""
|
||||
Audio engine started successfully:
|
||||
- Device: \(deviceName)
|
||||
- Device ID: \(currentDeviceID)
|
||||
- Device Type: \(isBluetoothDevice ? "Bluetooth" : "Standard")
|
||||
- Engine Status: Running
|
||||
""")
|
||||
}
|
||||
} catch {
|
||||
logger.error("""
|
||||
Audio engine start failed:
|
||||
- Error: \(error.localizedDescription)
|
||||
- Error Details: \(error)
|
||||
- Current Device ID: \(self.deviceManager.getCurrentDevice())
|
||||
- Engine State: \(self.engine.isRunning ? "Running" : "Stopped")
|
||||
""")
|
||||
// Clean up on failure
|
||||
stopAudioEngine()
|
||||
}
|
||||
}
|
||||
|
||||
func stopAudioEngine() {
|
||||
guard isRunning else { return }
|
||||
|
||||
logger.info("Stopping audio engine")
|
||||
if let tap = inputTap {
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
inputTap = nil
|
||||
}
|
||||
|
||||
engine.stop()
|
||||
|
||||
// Complete cleanup of the engine
|
||||
engine = AVAudioEngine() // Create a fresh instance
|
||||
mixer = AVAudioMixerNode() // Reset mixer
|
||||
|
||||
isRunning = false
|
||||
audioLevel = 0.0
|
||||
logger.info("Audio engine stopped and reset")
|
||||
}
|
||||
|
||||
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
|
||||
guard let channelData = buffer.floatChannelData?[0] else { return }
|
||||
let frameCount = buffer.frameLength
|
||||
|
||||
let currentTime = CACurrentMediaTime()
|
||||
guard currentTime - lastUpdateTime >= updateInterval else { return }
|
||||
lastUpdateTime = currentTime
|
||||
|
||||
// Use vDSP for faster processing
|
||||
var sum: Float = 0
|
||||
for frame in 0..<Int(frameCount) {
|
||||
let sample = abs(channelData[frame])
|
||||
sum += sample
|
||||
}
|
||||
|
||||
let average = sum / Float(frameCount)
|
||||
let level = CGFloat(average)
|
||||
|
||||
// Apply higher scaling for built-in microphone
|
||||
let currentDeviceID = deviceManager.getCurrentDevice()
|
||||
let isBuiltInMic = deviceManager.getDeviceName(deviceID: currentDeviceID)?.lowercased().contains("built-in") ?? false
|
||||
let scalingFactor: CGFloat = isBuiltInMic ? 11.0 : 5.0 // Higher scaling for built-in mic
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.audioLevel = min(max(level * scalingFactor, 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let observer = deviceObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
stopAudioEngine()
|
||||
}
|
||||
}
|
||||
|
||||
44
VoiceInk/ClipboardManager.swift
Normal file
@ -0,0 +1,44 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ClipboardManager {
|
||||
static func copyToClipboard(_ text: String) {
|
||||
#if os(macOS)
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
#else
|
||||
UIPasteboard.general.string = text
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct ClipboardMessageModifier: ViewModifier {
|
||||
@Binding var message: String
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay(
|
||||
Group {
|
||||
if !message.isEmpty {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundColor(.green)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.green.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
.transition(.opacity)
|
||||
.animation(.easeInOut, value: message)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
||||
.padding()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func clipboardMessage(_ message: Binding<String>) -> some View {
|
||||
self.modifier(ClipboardMessageModifier(message: message))
|
||||
}
|
||||
}
|
||||
44
VoiceInk/CursorPaster.swift
Normal file
@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import Cocoa
|
||||
|
||||
class CursorPaster {
|
||||
static func pasteAtCursor(_ text: String) {
|
||||
guard AXIsProcessTrusted() else {
|
||||
print("Accessibility permissions not granted. Cannot paste at cursor.")
|
||||
return
|
||||
}
|
||||
|
||||
// Save the current pasteboard contents
|
||||
let pasteboard = NSPasteboard.general
|
||||
let oldContents = pasteboard.string(forType: .string)
|
||||
|
||||
// Set the new text to paste
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
|
||||
// Simulate cmd+v key press to paste
|
||||
let source = CGEventSource(stateID: .hidSystemState)
|
||||
|
||||
let cmdDown = CGEvent(keyboardEventSource: source, virtualKey: 0x37, keyDown: true)
|
||||
let vDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true)
|
||||
let vUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false)
|
||||
let cmdUp = CGEvent(keyboardEventSource: source, virtualKey: 0x37, keyDown: false)
|
||||
|
||||
cmdDown?.flags = .maskCommand
|
||||
vDown?.flags = .maskCommand
|
||||
vUp?.flags = .maskCommand
|
||||
|
||||
cmdDown?.post(tap: .cghidEventTap)
|
||||
vDown?.post(tap: .cghidEventTap)
|
||||
vUp?.post(tap: .cghidEventTap)
|
||||
cmdUp?.post(tap: .cghidEventTap)
|
||||
|
||||
// Restore the original pasteboard contents
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
if let oldContents = oldContents {
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(oldContents, forType: .string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
195
VoiceInk/DataMigrationManager.swift
Normal file
@ -0,0 +1,195 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
class DataMigrationManager {
|
||||
private let logger = Logger(
|
||||
subsystem: "com.prakashjoshipax.VoiceInk",
|
||||
category: "DataMigration"
|
||||
)
|
||||
|
||||
static let shared = DataMigrationManager()
|
||||
private let swiftDataMigrationKey = "hasPerformedSwiftDataMigration"
|
||||
private let whisperModelsMigrationKey = "hasPerformedWhisperModelsMigration"
|
||||
private let preferencesMigrationKey = "hasPerformedPreferencesMigration"
|
||||
|
||||
private init() {}
|
||||
|
||||
func performMigrationsIfNeeded() {
|
||||
migratePreferencesIfNeeded() // Do preferences first as other migrations might need new preferences
|
||||
migrateSwiftDataStoreIfNeeded()
|
||||
migrateWhisperModelsIfNeeded()
|
||||
}
|
||||
|
||||
private func migratePreferencesIfNeeded() {
|
||||
// Check if migration has already been performed
|
||||
if UserDefaults.standard.bool(forKey: preferencesMigrationKey) {
|
||||
logger.info("Preferences migration already performed")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("Starting preferences migration")
|
||||
|
||||
let bundleId = "com.prakashjoshipax.VoiceInk"
|
||||
|
||||
// Old location (in Containers)
|
||||
let oldPrefsURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Containers/\(bundleId)/Data/Library/Preferences/\(bundleId).plist")
|
||||
|
||||
// New location
|
||||
let newPrefsURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Preferences/\(bundleId).plist")
|
||||
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: oldPrefsURL.path) {
|
||||
logger.info("Found old preferences at: \(oldPrefsURL.path)")
|
||||
|
||||
// Read old preferences
|
||||
let oldPrefsData = try Data(contentsOf: oldPrefsURL)
|
||||
if let oldPrefs = try PropertyListSerialization.propertyList(from: oldPrefsData, format: nil) as? [String: Any] {
|
||||
|
||||
// Migrate each preference to new UserDefaults
|
||||
for (key, value) in oldPrefs {
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
logger.info("Migrated preference: \(key)")
|
||||
}
|
||||
|
||||
// Ensure changes are written to disk
|
||||
UserDefaults.standard.synchronize()
|
||||
|
||||
// Try to remove old preferences file
|
||||
try? FileManager.default.removeItem(at: oldPrefsURL)
|
||||
logger.info("Removed old preferences file")
|
||||
}
|
||||
|
||||
// Mark migration as complete
|
||||
UserDefaults.standard.set(true, forKey: preferencesMigrationKey)
|
||||
logger.info("Preferences migration completed successfully")
|
||||
} else {
|
||||
logger.info("No old preferences file found at: \(oldPrefsURL.path)")
|
||||
// Mark migration as complete even if no old prefs found
|
||||
UserDefaults.standard.set(true, forKey: preferencesMigrationKey)
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to migrate preferences: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func migrateSwiftDataStoreIfNeeded() {
|
||||
// Check if migration has already been performed
|
||||
if UserDefaults.standard.bool(forKey: swiftDataMigrationKey) {
|
||||
logger.info("SwiftData migration already performed")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("Starting SwiftData store migration")
|
||||
|
||||
// Old location (in Containers directory)
|
||||
let oldContainerURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.deletingLastPathComponent() // Go up to Library
|
||||
.appendingPathComponent("Containers/com.prakashjoshipax.VoiceInk/Data/Library/Application Support")
|
||||
|
||||
// New location (in Application Support)
|
||||
let newContainerURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("com.prakashjoshipax.VoiceInk")
|
||||
|
||||
let storeFiles = [
|
||||
"default.store",
|
||||
"default.store-wal",
|
||||
"default.store-shm"
|
||||
]
|
||||
|
||||
do {
|
||||
// Create new directory if it doesn't exist
|
||||
try FileManager.default.createDirectory(at: newContainerURL, withIntermediateDirectories: true)
|
||||
|
||||
// Migrate each store file
|
||||
for fileName in storeFiles {
|
||||
let oldURL = oldContainerURL.appendingPathComponent(fileName)
|
||||
let newURL = newContainerURL.appendingPathComponent(fileName)
|
||||
|
||||
if FileManager.default.fileExists(atPath: oldURL.path) {
|
||||
logger.info("Migrating \(fileName)")
|
||||
|
||||
// If a file already exists at the destination, remove it first
|
||||
if FileManager.default.fileExists(atPath: newURL.path) {
|
||||
try FileManager.default.removeItem(at: newURL)
|
||||
}
|
||||
|
||||
// Copy the file to new location
|
||||
try FileManager.default.copyItem(at: oldURL, to: newURL)
|
||||
|
||||
// Remove the old file
|
||||
try FileManager.default.removeItem(at: oldURL)
|
||||
|
||||
logger.info("Successfully migrated \(fileName)")
|
||||
} else {
|
||||
logger.info("No \(fileName) found at old location")
|
||||
}
|
||||
}
|
||||
|
||||
// Mark migration as complete
|
||||
UserDefaults.standard.set(true, forKey: swiftDataMigrationKey)
|
||||
logger.info("SwiftData migration completed successfully")
|
||||
|
||||
} catch {
|
||||
logger.error("Failed to migrate SwiftData store: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func migrateWhisperModelsIfNeeded() {
|
||||
// Check if migration has already been performed
|
||||
if UserDefaults.standard.bool(forKey: whisperModelsMigrationKey) {
|
||||
logger.info("Whisper models migration already performed")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("Starting Whisper models migration")
|
||||
|
||||
// Old location (in Containers directory)
|
||||
let oldModelsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
.deletingLastPathComponent() // Go up to Documents
|
||||
.deletingLastPathComponent() // Go up to Data
|
||||
.deletingLastPathComponent() // Go up to com.prakashjoshipax.VoiceInk
|
||||
.deletingLastPathComponent() // Go up to Containers
|
||||
.appendingPathComponent("com.prakashjoshipax.VoiceInk/Data/Documents/WhisperModels")
|
||||
|
||||
// New location (in Documents)
|
||||
let newModelsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("WhisperModels")
|
||||
|
||||
do {
|
||||
// Create new directory if it doesn't exist
|
||||
try FileManager.default.createDirectory(at: newModelsURL, withIntermediateDirectories: true)
|
||||
|
||||
// Get all files in the old directory
|
||||
if let modelFiles = try? FileManager.default.contentsOfDirectory(at: oldModelsURL, includingPropertiesForKeys: nil) {
|
||||
for modelURL in modelFiles {
|
||||
let fileName = modelURL.lastPathComponent
|
||||
let newURL = newModelsURL.appendingPathComponent(fileName)
|
||||
|
||||
logger.info("Migrating model: \(fileName)")
|
||||
|
||||
// If a file already exists at the destination, remove it first
|
||||
if FileManager.default.fileExists(atPath: newURL.path) {
|
||||
try FileManager.default.removeItem(at: newURL)
|
||||
}
|
||||
|
||||
// Copy the file to new location
|
||||
try FileManager.default.copyItem(at: modelURL, to: newURL)
|
||||
|
||||
// Remove the old file
|
||||
try FileManager.default.removeItem(at: modelURL)
|
||||
|
||||
logger.info("Successfully migrated model: \(fileName)")
|
||||
}
|
||||
}
|
||||
|
||||
// Mark migration as complete
|
||||
UserDefaults.standard.set(true, forKey: whisperModelsMigrationKey)
|
||||
logger.info("Whisper models migration completed successfully")
|
||||
|
||||
} catch {
|
||||
logger.error("Failed to migrate Whisper models: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
299
VoiceInk/HotkeyManager.swift
Normal file
@ -0,0 +1,299 @@
|
||||
import Foundation
|
||||
import KeyboardShortcuts
|
||||
import Carbon
|
||||
import AppKit
|
||||
|
||||
extension KeyboardShortcuts.Name {
|
||||
static let toggleMiniRecorder = Self("toggleMiniRecorder")
|
||||
static let escapeRecorder = Self("escapeRecorder")
|
||||
static let toggleEnhancement = Self("toggleEnhancement")
|
||||
// Prompt selection shortcuts
|
||||
static let selectPrompt1 = Self("selectPrompt1")
|
||||
static let selectPrompt2 = Self("selectPrompt2")
|
||||
static let selectPrompt3 = Self("selectPrompt3")
|
||||
static let selectPrompt4 = Self("selectPrompt4")
|
||||
static let selectPrompt5 = Self("selectPrompt5")
|
||||
static let selectPrompt6 = Self("selectPrompt6")
|
||||
static let selectPrompt7 = Self("selectPrompt7")
|
||||
static let selectPrompt8 = Self("selectPrompt8")
|
||||
static let selectPrompt9 = Self("selectPrompt9")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class HotkeyManager: ObservableObject {
|
||||
@Published var isListening = false
|
||||
@Published var isShortcutConfigured = false
|
||||
@Published var isPushToTalkEnabled: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(isPushToTalkEnabled, forKey: "isPushToTalkEnabled")
|
||||
if !isPushToTalkEnabled {
|
||||
isRightOptionKeyPressed = false
|
||||
isFnKeyPressed = false
|
||||
isRightCommandKeyPressed = false
|
||||
}
|
||||
setupKeyMonitors()
|
||||
}
|
||||
}
|
||||
@Published var pushToTalkKey: PushToTalkKey {
|
||||
didSet {
|
||||
UserDefaults.standard.set(pushToTalkKey.rawValue, forKey: "pushToTalkKey")
|
||||
isRightOptionKeyPressed = false
|
||||
isFnKeyPressed = false
|
||||
isRightCommandKeyPressed = false
|
||||
}
|
||||
}
|
||||
|
||||
private var whisperState: WhisperState
|
||||
private var isRightOptionKeyPressed = false
|
||||
private var isFnKeyPressed = false
|
||||
private var isRightCommandKeyPressed = false
|
||||
private var localKeyMonitor: Any?
|
||||
private var globalKeyMonitor: Any?
|
||||
private var visibilityTask: Task<Void, Never>?
|
||||
|
||||
// Add cooldown management
|
||||
private var lastShortcutTriggerTime: Date?
|
||||
private let shortcutCooldownInterval: TimeInterval = 0.5 // 500ms cooldown
|
||||
|
||||
enum PushToTalkKey: String, CaseIterable {
|
||||
case rightOption = "rightOption"
|
||||
case fn = "fn"
|
||||
case rightCommand = "rightCommand"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .rightOption: return "Right Option (⌥)"
|
||||
case .fn: return "Fn"
|
||||
case .rightCommand: return "Right Command (⌘)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(whisperState: WhisperState) {
|
||||
self.isPushToTalkEnabled = UserDefaults.standard.bool(forKey: "isPushToTalkEnabled")
|
||||
self.pushToTalkKey = PushToTalkKey(rawValue: UserDefaults.standard.string(forKey: "pushToTalkKey") ?? "") ?? .rightOption
|
||||
self.whisperState = whisperState
|
||||
|
||||
updateShortcutStatus()
|
||||
setupEnhancementShortcut()
|
||||
|
||||
// Start observing mini recorder visibility
|
||||
setupVisibilityObserver()
|
||||
}
|
||||
|
||||
private func setupVisibilityObserver() {
|
||||
visibilityTask = Task { @MainActor in
|
||||
for await isVisible in whisperState.$isMiniRecorderVisible.values {
|
||||
if isVisible {
|
||||
setupEscapeShortcut()
|
||||
// Set Command+E shortcut when visible
|
||||
KeyboardShortcuts.setShortcut(.init(.e, modifiers: .command), for: .toggleEnhancement)
|
||||
setupPromptShortcuts()
|
||||
} else {
|
||||
removeEscapeShortcut()
|
||||
// Remove Command+E shortcut when not visible
|
||||
KeyboardShortcuts.setShortcut(nil, for: .toggleEnhancement)
|
||||
removePromptShortcuts()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupEscapeShortcut() {
|
||||
// Set ESC as the shortcut using KeyboardShortcuts native approach
|
||||
KeyboardShortcuts.setShortcut(.init(.escape), for: .escapeRecorder)
|
||||
|
||||
// Setup handler
|
||||
KeyboardShortcuts.onKeyDown(for: .escapeRecorder) { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self = self,
|
||||
await self.whisperState.isMiniRecorderVisible else { return }
|
||||
await self.whisperState.dismissMiniRecorder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeEscapeShortcut() {
|
||||
KeyboardShortcuts.setShortcut(nil, for: .escapeRecorder)
|
||||
}
|
||||
|
||||
private func setupEnhancementShortcut() {
|
||||
// Only setup the handler, don't set the shortcut here
|
||||
// The shortcut will be set/removed based on visibility
|
||||
KeyboardShortcuts.onKeyDown(for: .toggleEnhancement) { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self = self,
|
||||
await self.whisperState.isMiniRecorderVisible,
|
||||
let enhancementService = await self.whisperState.getEnhancementService() else { return }
|
||||
enhancementService.isEnhancementEnabled.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeEnhancementShortcut() {
|
||||
KeyboardShortcuts.setShortcut(nil, for: .toggleEnhancement)
|
||||
}
|
||||
|
||||
private func setupPromptShortcuts() {
|
||||
// Set up Command+1 through Command+9 shortcuts with proper key definitions
|
||||
KeyboardShortcuts.setShortcut(.init(.one, modifiers: .command), for: .selectPrompt1)
|
||||
KeyboardShortcuts.setShortcut(.init(.two, modifiers: .command), for: .selectPrompt2)
|
||||
KeyboardShortcuts.setShortcut(.init(.three, modifiers: .command), for: .selectPrompt3)
|
||||
KeyboardShortcuts.setShortcut(.init(.four, modifiers: .command), for: .selectPrompt4)
|
||||
KeyboardShortcuts.setShortcut(.init(.five, modifiers: .command), for: .selectPrompt5)
|
||||
KeyboardShortcuts.setShortcut(.init(.six, modifiers: .command), for: .selectPrompt6)
|
||||
KeyboardShortcuts.setShortcut(.init(.seven, modifiers: .command), for: .selectPrompt7)
|
||||
KeyboardShortcuts.setShortcut(.init(.eight, modifiers: .command), for: .selectPrompt8)
|
||||
KeyboardShortcuts.setShortcut(.init(.nine, modifiers: .command), for: .selectPrompt9)
|
||||
|
||||
// Setup handlers for each shortcut
|
||||
setupPromptHandler(for: .selectPrompt1, index: 0)
|
||||
setupPromptHandler(for: .selectPrompt2, index: 1)
|
||||
setupPromptHandler(for: .selectPrompt3, index: 2)
|
||||
setupPromptHandler(for: .selectPrompt4, index: 3)
|
||||
setupPromptHandler(for: .selectPrompt5, index: 4)
|
||||
setupPromptHandler(for: .selectPrompt6, index: 5)
|
||||
setupPromptHandler(for: .selectPrompt7, index: 6)
|
||||
setupPromptHandler(for: .selectPrompt8, index: 7)
|
||||
setupPromptHandler(for: .selectPrompt9, index: 8)
|
||||
}
|
||||
|
||||
private func setupPromptHandler(for shortcutName: KeyboardShortcuts.Name, index: Int) {
|
||||
KeyboardShortcuts.onKeyDown(for: shortcutName) { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self = self,
|
||||
await self.whisperState.isMiniRecorderVisible,
|
||||
let enhancementService = await self.whisperState.getEnhancementService() else { return }
|
||||
|
||||
let prompts = enhancementService.allPrompts
|
||||
if index < prompts.count {
|
||||
// Enable AI enhancement if it's not already enabled
|
||||
if !enhancementService.isEnhancementEnabled {
|
||||
enhancementService.isEnhancementEnabled = true
|
||||
}
|
||||
// Switch to the selected prompt
|
||||
enhancementService.setActivePrompt(prompts[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removePromptShortcuts() {
|
||||
// Remove Command+1 through Command+9 shortcuts
|
||||
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt1)
|
||||
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt2)
|
||||
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt3)
|
||||
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt4)
|
||||
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt5)
|
||||
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt6)
|
||||
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt7)
|
||||
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt8)
|
||||
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt9)
|
||||
}
|
||||
|
||||
func updateShortcutStatus() {
|
||||
isShortcutConfigured = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil
|
||||
|
||||
if isShortcutConfigured {
|
||||
setupShortcutHandler()
|
||||
setupKeyMonitors()
|
||||
} else {
|
||||
removeKeyMonitors()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupShortcutHandler() {
|
||||
KeyboardShortcuts.onKeyUp(for: .toggleMiniRecorder) { [weak self] in
|
||||
Task { @MainActor in
|
||||
await self?.handleShortcutTriggered()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleShortcutTriggered() async {
|
||||
// Check cooldown
|
||||
if let lastTrigger = lastShortcutTriggerTime,
|
||||
Date().timeIntervalSince(lastTrigger) < shortcutCooldownInterval {
|
||||
return // Still in cooldown period
|
||||
}
|
||||
|
||||
// Update last trigger time
|
||||
lastShortcutTriggerTime = Date()
|
||||
|
||||
// Handle the shortcut
|
||||
await whisperState.handleToggleMiniRecorder()
|
||||
}
|
||||
|
||||
private func removeKeyMonitors() {
|
||||
if let monitor = localKeyMonitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
localKeyMonitor = nil
|
||||
}
|
||||
if let monitor = globalKeyMonitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
globalKeyMonitor = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func setupKeyMonitors() {
|
||||
guard isPushToTalkEnabled else {
|
||||
removeKeyMonitors()
|
||||
return
|
||||
}
|
||||
|
||||
// Remove existing monitors first
|
||||
removeKeyMonitors()
|
||||
|
||||
// Local monitor for when app is in foreground
|
||||
localKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
Task { @MainActor in
|
||||
await self?.handlePushToTalkKey(event)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
// Global monitor for when app is in background
|
||||
globalKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
Task { @MainActor in
|
||||
await self?.handlePushToTalkKey(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePushToTalkKey(_ event: NSEvent) async {
|
||||
// Only handle push-to-talk if enabled and configured
|
||||
guard isPushToTalkEnabled && isShortcutConfigured else { return }
|
||||
|
||||
let keyState: Bool
|
||||
switch pushToTalkKey {
|
||||
case .rightOption:
|
||||
keyState = event.modifierFlags.contains(.option) && event.keyCode == 0x3D
|
||||
guard keyState != isRightOptionKeyPressed else { return }
|
||||
isRightOptionKeyPressed = keyState
|
||||
|
||||
case .fn:
|
||||
keyState = event.modifierFlags.contains(.function)
|
||||
guard keyState != isFnKeyPressed else { return }
|
||||
isFnKeyPressed = keyState
|
||||
|
||||
case .rightCommand:
|
||||
keyState = event.modifierFlags.contains(.command) && event.keyCode == 0x36
|
||||
guard keyState != isRightCommandKeyPressed else { return }
|
||||
isRightCommandKeyPressed = keyState
|
||||
}
|
||||
|
||||
// Toggle recording based on key state
|
||||
if whisperState.isMiniRecorderVisible != keyState {
|
||||
await whisperState.handleToggleMiniRecorder()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
visibilityTask?.cancel()
|
||||
Task { @MainActor in
|
||||
removeKeyMonitors()
|
||||
removeEscapeShortcut()
|
||||
removeEnhancementShortcut()
|
||||
}
|
||||
}
|
||||
}
|
||||
20
VoiceInk/Info.plist
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SUEnableInstallerLauncherService</key>
|
||||
<true/>
|
||||
<key>SUFeedURL</key>
|
||||
<string>https://beingpax.github.io/VoiceInk/appcast.xml</string>
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>rLRdZIjK3gHKfqNlAF9nT7FbjwSvwkJ8BVn0v2mD1Mo=</string>
|
||||
<key>LSUIElement</key>
|
||||
<false/>
|
||||
<key>SUEnableAutomaticChecks</key>
|
||||
<true/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>VoiceInk needs access to your microphone to record audio for transcription.</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>VoiceInk needs to interact with your browser to detect the current website for applying website-specific configurations.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
140
VoiceInk/LibWhisper.swift
Normal file
@ -0,0 +1,140 @@
|
||||
import Foundation
|
||||
#if canImport(whisper)
|
||||
import whisper
|
||||
#else
|
||||
#error("Unable to import whisper module. Please check your project configuration.")
|
||||
#endif
|
||||
|
||||
enum WhisperError: Error {
|
||||
case couldNotInitializeContext
|
||||
}
|
||||
|
||||
// Meet Whisper C++ constraint: Don't access from more than one thread at a time.
|
||||
actor WhisperContext {
|
||||
private var context: OpaquePointer?
|
||||
private var languageCString: [CChar]?
|
||||
private var prompt: String?
|
||||
private var promptCString: [CChar]?
|
||||
|
||||
init(context: OpaquePointer) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let context = context {
|
||||
whisper_free(context)
|
||||
}
|
||||
}
|
||||
|
||||
func fullTranscribe(samples: [Float]) {
|
||||
guard let context = context else { return }
|
||||
|
||||
// Leave 2 processors free (i.e. the high-efficiency cores).
|
||||
let maxThreads = max(1, min(8, cpuCount() - 2))
|
||||
print("Selecting \(maxThreads) threads")
|
||||
var params = whisper_full_default_params(WHISPER_SAMPLING_GREEDY)
|
||||
|
||||
// Read language directly from UserDefaults
|
||||
let selectedLanguage = UserDefaults.standard.string(forKey: "SelectedLanguage") ?? "auto"
|
||||
if selectedLanguage != "auto" {
|
||||
languageCString = Array(selectedLanguage.utf8CString)
|
||||
params.language = languageCString?.withUnsafeBufferPointer { ptr in
|
||||
ptr.baseAddress
|
||||
}
|
||||
print("Setting language to: \(selectedLanguage)")
|
||||
} else {
|
||||
languageCString = nil
|
||||
params.language = nil
|
||||
print("Using auto-detection")
|
||||
}
|
||||
|
||||
// Only use prompt for English language
|
||||
if selectedLanguage == "en" && prompt != nil {
|
||||
promptCString = Array(prompt!.utf8CString)
|
||||
params.initial_prompt = promptCString?.withUnsafeBufferPointer { ptr in
|
||||
ptr.baseAddress
|
||||
}
|
||||
print("Using prompt for English transcription: \(prompt!)")
|
||||
} else {
|
||||
promptCString = nil
|
||||
params.initial_prompt = nil
|
||||
if selectedLanguage == "en" {
|
||||
print("No prompt set for English")
|
||||
} else {
|
||||
print("Prompt disabled for non-English language")
|
||||
}
|
||||
}
|
||||
|
||||
// Adapted from whisper.objc
|
||||
params.print_realtime = true
|
||||
params.print_progress = false
|
||||
params.print_timestamps = true
|
||||
params.print_special = false
|
||||
params.translate = false
|
||||
params.n_threads = Int32(maxThreads)
|
||||
params.offset_ms = 0
|
||||
params.no_context = true
|
||||
params.single_segment = false
|
||||
|
||||
whisper_reset_timings(context)
|
||||
print("About to run whisper_full")
|
||||
samples.withUnsafeBufferPointer { samples in
|
||||
if (whisper_full(context, params, samples.baseAddress, Int32(samples.count)) != 0) {
|
||||
print("Failed to run the model")
|
||||
} else {
|
||||
// Print detected language info before timings
|
||||
let langId = whisper_full_lang_id(context)
|
||||
let detectedLang = String(cString: whisper_lang_str(langId))
|
||||
print("Transcription completed - Selected: \(selectedLanguage), Used: \(detectedLang)")
|
||||
whisper_print_timings(context)
|
||||
}
|
||||
}
|
||||
|
||||
languageCString = nil
|
||||
promptCString = nil
|
||||
}
|
||||
|
||||
func getTranscription() -> String {
|
||||
guard let context = context else { return "" }
|
||||
var transcription = ""
|
||||
for i in 0..<whisper_full_n_segments(context) {
|
||||
transcription += String(cString: whisper_full_get_segment_text(context, i))
|
||||
}
|
||||
return transcription
|
||||
}
|
||||
|
||||
static func createContext(path: String) async throws -> WhisperContext {
|
||||
return try await Task.detached {
|
||||
var params = whisper_context_default_params()
|
||||
#if targetEnvironment(simulator)
|
||||
params.use_gpu = false
|
||||
print("Running on the simulator, using CPU")
|
||||
#endif
|
||||
let context = whisper_init_from_file_with_params(path, params)
|
||||
if let context {
|
||||
return WhisperContext(context: context)
|
||||
} else {
|
||||
print("Couldn't load model at \(path)")
|
||||
throw WhisperError.couldNotInitializeContext
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
func releaseResources() {
|
||||
if let context = context {
|
||||
whisper_free(context)
|
||||
self.context = nil
|
||||
}
|
||||
languageCString = nil
|
||||
}
|
||||
|
||||
func setPrompt(_ prompt: String?) {
|
||||
self.prompt = prompt
|
||||
print("Prompt set to: \(prompt ?? "none")")
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func cpuCount() -> Int {
|
||||
ProcessInfo.processInfo.processorCount
|
||||
}
|
||||
|
||||
143
VoiceInk/MenuBarManager.swift
Normal file
@ -0,0 +1,143 @@
|
||||
import SwiftUI
|
||||
import LaunchAtLogin
|
||||
import SwiftData
|
||||
import AppKit
|
||||
|
||||
class MenuBarManager: ObservableObject {
|
||||
@Published var isMenuBarOnly: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(isMenuBarOnly, forKey: "IsMenuBarOnly")
|
||||
updateAppActivationPolicy()
|
||||
}
|
||||
}
|
||||
|
||||
private var updaterViewModel: UpdaterViewModel
|
||||
private var whisperState: WhisperState
|
||||
private var container: ModelContainer
|
||||
private var enhancementService: AIEnhancementService
|
||||
private var aiService: AIService
|
||||
private var hotkeyManager: HotkeyManager
|
||||
private var mainWindow: NSWindow? // Store window reference
|
||||
|
||||
init(updaterViewModel: UpdaterViewModel,
|
||||
whisperState: WhisperState,
|
||||
container: ModelContainer,
|
||||
enhancementService: AIEnhancementService,
|
||||
aiService: AIService,
|
||||
hotkeyManager: HotkeyManager) {
|
||||
self.isMenuBarOnly = UserDefaults.standard.bool(forKey: "IsMenuBarOnly")
|
||||
self.updaterViewModel = updaterViewModel
|
||||
self.whisperState = whisperState
|
||||
self.container = container
|
||||
self.enhancementService = enhancementService
|
||||
self.aiService = aiService
|
||||
self.hotkeyManager = hotkeyManager
|
||||
updateAppActivationPolicy()
|
||||
}
|
||||
|
||||
func toggleMenuBarOnly() {
|
||||
isMenuBarOnly.toggle()
|
||||
}
|
||||
|
||||
private func updateAppActivationPolicy() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Clean up existing window if switching to menu bar mode
|
||||
if self.isMenuBarOnly && self.mainWindow != nil {
|
||||
self.mainWindow?.close()
|
||||
self.mainWindow = nil
|
||||
}
|
||||
|
||||
// Update activation policy
|
||||
if self.isMenuBarOnly {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
} else {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openMainWindowAndNavigate(to destination: String) {
|
||||
print("MenuBarManager: Navigating to \(destination)")
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Activate the app
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
// Clean up existing window if it's no longer valid
|
||||
if let existingWindow = self.mainWindow, !existingWindow.isVisible {
|
||||
self.mainWindow = nil
|
||||
}
|
||||
|
||||
// Get or create main window
|
||||
if self.mainWindow == nil {
|
||||
self.mainWindow = self.createMainWindow()
|
||||
}
|
||||
|
||||
guard let window = self.mainWindow else { return }
|
||||
|
||||
// Make the window key and order front
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.center() // Center the window on screen
|
||||
|
||||
// Post a notification to navigate to the desired destination
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
NotificationCenter.default.post(
|
||||
name: .navigateToDestination,
|
||||
object: nil,
|
||||
userInfo: ["destination": destination]
|
||||
)
|
||||
print("MenuBarManager: Posted navigation notification for \(destination)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createMainWindow() -> NSWindow {
|
||||
print("MenuBarManager: Creating new main window")
|
||||
|
||||
// Create the content view with all required environment objects
|
||||
let contentView = ContentView()
|
||||
.environmentObject(whisperState)
|
||||
.environmentObject(hotkeyManager)
|
||||
.environmentObject(self)
|
||||
.environmentObject(updaterViewModel)
|
||||
.environmentObject(enhancementService)
|
||||
.environmentObject(aiService)
|
||||
.environment(\.modelContext, ModelContext(container))
|
||||
|
||||
// Create window using WindowManager
|
||||
let hostingView = NSHostingView(rootView: contentView)
|
||||
let window = WindowManager.shared.createMainWindow(contentView: hostingView)
|
||||
|
||||
// Set window delegate to handle window closing
|
||||
let delegate = WindowDelegate { [weak self] in
|
||||
self?.mainWindow = nil
|
||||
}
|
||||
window.delegate = delegate
|
||||
|
||||
print("MenuBarManager: Window setup complete")
|
||||
|
||||
return window
|
||||
}
|
||||
}
|
||||
|
||||
// Window delegate to handle window closing
|
||||
class WindowDelegate: NSObject, NSWindowDelegate {
|
||||
let onClose: () -> Void
|
||||
|
||||
init(onClose: @escaping () -> Void) {
|
||||
self.onClose = onClose
|
||||
super.init()
|
||||
}
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let navigateToDestination = Notification.Name("navigateToDestination")
|
||||
}
|
||||
103
VoiceInk/Models/CustomPrompt.swift
Normal file
@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
|
||||
enum PromptIcon: String, Codable, CaseIterable {
|
||||
// Document & Text
|
||||
case documentFill = "doc.text.fill"
|
||||
case textbox = "textbox"
|
||||
case sealedFill = "checkmark.seal.fill"
|
||||
|
||||
// Communication
|
||||
case chatFill = "bubble.left.and.bubble.right.fill"
|
||||
case messageFill = "message.fill"
|
||||
case emailFill = "envelope.fill"
|
||||
|
||||
// Professional
|
||||
case meetingFill = "person.2.fill"
|
||||
case presentationFill = "person.wave.2.fill"
|
||||
case briefcaseFill = "briefcase.fill"
|
||||
|
||||
// Technical
|
||||
case codeFill = "chevron.left.forwardslash.chevron.right.fill"
|
||||
case terminalFill = "terminal.fill"
|
||||
case gearFill = "gearshape.fill"
|
||||
|
||||
// Content
|
||||
case blogFill = "doc.text.image.fill"
|
||||
case notesFill = "note.text"
|
||||
case bookFill = "book.fill"
|
||||
case bookmarkFill = "bookmark.fill"
|
||||
case pencilFill = "pencil.circle.fill"
|
||||
|
||||
// Media & Creative
|
||||
case videoFill = "video.fill"
|
||||
case micFill = "mic.fill"
|
||||
case musicFill = "music.note.list"
|
||||
case photoFill = "photo.fill"
|
||||
case brushFill = "paintbrush.fill"
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
// Document & Text
|
||||
case .documentFill: return "Document"
|
||||
case .textbox: return "Textbox"
|
||||
case .sealedFill: return "Sealed"
|
||||
|
||||
// Communication
|
||||
case .chatFill: return "Chat"
|
||||
case .messageFill: return "Message"
|
||||
case .emailFill: return "Email"
|
||||
|
||||
// Professional
|
||||
case .meetingFill: return "Meeting"
|
||||
case .presentationFill: return "Presentation"
|
||||
case .briefcaseFill: return "Briefcase"
|
||||
|
||||
// Technical
|
||||
case .codeFill: return "Code"
|
||||
case .terminalFill: return "Terminal"
|
||||
case .gearFill: return "Settings"
|
||||
|
||||
// Content
|
||||
case .blogFill: return "Blog"
|
||||
case .notesFill: return "Notes"
|
||||
case .bookFill: return "Book"
|
||||
case .bookmarkFill: return "Bookmark"
|
||||
case .pencilFill: return "Edit"
|
||||
|
||||
// Media & Creative
|
||||
case .videoFill: return "Video"
|
||||
case .micFill: return "Audio"
|
||||
case .musicFill: return "Music"
|
||||
case .photoFill: return "Photo"
|
||||
case .brushFill: return "Design"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomPrompt: Identifiable, Codable, Equatable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let promptText: String
|
||||
var isActive: Bool
|
||||
let icon: PromptIcon
|
||||
let description: String?
|
||||
let isPredefined: Bool
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
title: String,
|
||||
promptText: String,
|
||||
isActive: Bool = false,
|
||||
icon: PromptIcon = .documentFill,
|
||||
description: String? = nil,
|
||||
isPredefined: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.promptText = promptText
|
||||
self.isActive = isActive
|
||||
self.icon = icon
|
||||
self.description = description
|
||||
self.isPredefined = isPredefined
|
||||
}
|
||||
}
|
||||
153
VoiceInk/Models/PowerModeConfig.swift
Normal file
@ -0,0 +1,153 @@
|
||||
import Foundation
|
||||
|
||||
struct PowerModeConfig: Codable, Identifiable, Equatable {
|
||||
var id: String { bundleIdentifier }
|
||||
let bundleIdentifier: String
|
||||
var appName: String
|
||||
var isAIEnhancementEnabled: Bool
|
||||
var selectedPrompt: String? // UUID string of the selected prompt
|
||||
var urlConfigs: [URLConfig]? // Optional URL configurations
|
||||
|
||||
static func == (lhs: PowerModeConfig, rhs: PowerModeConfig) -> Bool {
|
||||
lhs.bundleIdentifier == rhs.bundleIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
// Simple URL configuration
|
||||
struct URLConfig: Codable, Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var url: String // Simple URL like "google.com"
|
||||
var promptId: String? // UUID string of the selected prompt for this URL
|
||||
|
||||
init(url: String, promptId: String? = nil) {
|
||||
self.id = UUID()
|
||||
self.url = url
|
||||
self.promptId = promptId
|
||||
}
|
||||
}
|
||||
|
||||
class PowerModeManager: ObservableObject {
|
||||
static let shared = PowerModeManager()
|
||||
@Published var configurations: [PowerModeConfig] = []
|
||||
@Published var defaultConfig: PowerModeConfig
|
||||
|
||||
private let configKey = "powerModeConfigurations"
|
||||
private let defaultConfigKey = "defaultPowerModeConfig"
|
||||
|
||||
private init() {
|
||||
// Initialize default config with default values
|
||||
if let data = UserDefaults.standard.data(forKey: defaultConfigKey),
|
||||
let config = try? JSONDecoder().decode(PowerModeConfig.self, from: data) {
|
||||
defaultConfig = config
|
||||
} else {
|
||||
defaultConfig = PowerModeConfig(
|
||||
bundleIdentifier: "default",
|
||||
appName: "Default Configuration",
|
||||
isAIEnhancementEnabled: true,
|
||||
selectedPrompt: nil
|
||||
)
|
||||
saveDefaultConfig()
|
||||
}
|
||||
loadConfigurations()
|
||||
}
|
||||
|
||||
private func loadConfigurations() {
|
||||
if let data = UserDefaults.standard.data(forKey: configKey),
|
||||
let configs = try? JSONDecoder().decode([PowerModeConfig].self, from: data) {
|
||||
configurations = configs
|
||||
}
|
||||
}
|
||||
|
||||
func saveConfigurations() {
|
||||
if let data = try? JSONEncoder().encode(configurations) {
|
||||
UserDefaults.standard.set(data, forKey: configKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveDefaultConfig() {
|
||||
if let data = try? JSONEncoder().encode(defaultConfig) {
|
||||
UserDefaults.standard.set(data, forKey: defaultConfigKey)
|
||||
}
|
||||
}
|
||||
|
||||
func addConfiguration(_ config: PowerModeConfig) {
|
||||
if !configurations.contains(config) {
|
||||
configurations.append(config)
|
||||
saveConfigurations()
|
||||
}
|
||||
}
|
||||
|
||||
func removeConfiguration(for bundleIdentifier: String) {
|
||||
configurations.removeAll { $0.bundleIdentifier == bundleIdentifier }
|
||||
saveConfigurations()
|
||||
}
|
||||
|
||||
func getConfiguration(for bundleIdentifier: String) -> PowerModeConfig? {
|
||||
if bundleIdentifier == "default" {
|
||||
return defaultConfig
|
||||
}
|
||||
return configurations.first { $0.bundleIdentifier == bundleIdentifier }
|
||||
}
|
||||
|
||||
func updateConfiguration(_ config: PowerModeConfig) {
|
||||
if config.bundleIdentifier == "default" {
|
||||
defaultConfig = config
|
||||
saveDefaultConfig()
|
||||
} else if let index = configurations.firstIndex(where: { $0.bundleIdentifier == config.bundleIdentifier }) {
|
||||
configurations[index] = config
|
||||
saveConfigurations()
|
||||
}
|
||||
}
|
||||
|
||||
// Get configuration for a specific URL
|
||||
func getConfigurationForURL(_ url: String) -> (config: PowerModeConfig, urlConfig: URLConfig)? {
|
||||
let cleanedURL = url.lowercased()
|
||||
.replacingOccurrences(of: "https://", with: "")
|
||||
.replacingOccurrences(of: "http://", with: "")
|
||||
.replacingOccurrences(of: "www.", with: "")
|
||||
|
||||
for config in configurations {
|
||||
if let urlConfigs = config.urlConfigs {
|
||||
for urlConfig in urlConfigs {
|
||||
let configURL = urlConfig.url.lowercased()
|
||||
.replacingOccurrences(of: "https://", with: "")
|
||||
.replacingOccurrences(of: "http://", with: "")
|
||||
.replacingOccurrences(of: "www.", with: "")
|
||||
|
||||
if cleanedURL.contains(configURL) {
|
||||
return (config, urlConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add URL configuration
|
||||
func addURLConfig(_ urlConfig: URLConfig, to config: PowerModeConfig) {
|
||||
if var updatedConfig = configurations.first(where: { $0.id == config.id }) {
|
||||
var configs = updatedConfig.urlConfigs ?? []
|
||||
configs.append(urlConfig)
|
||||
updatedConfig.urlConfigs = configs
|
||||
updateConfiguration(updatedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove URL configuration
|
||||
func removeURLConfig(_ urlConfig: URLConfig, from config: PowerModeConfig) {
|
||||
if var updatedConfig = configurations.first(where: { $0.id == config.id }) {
|
||||
updatedConfig.urlConfigs?.removeAll(where: { $0.id == urlConfig.id })
|
||||
updateConfiguration(updatedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// Update URL configuration
|
||||
func updateURLConfig(_ urlConfig: URLConfig, in config: PowerModeConfig) {
|
||||
if var updatedConfig = configurations.first(where: { $0.id == config.id }) {
|
||||
if let index = updatedConfig.urlConfigs?.firstIndex(where: { $0.id == urlConfig.id }) {
|
||||
updatedConfig.urlConfigs?[index] = urlConfig
|
||||
updateConfiguration(updatedConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
167
VoiceInk/Models/PredefinedModels.swift
Normal file
@ -0,0 +1,167 @@
|
||||
import Foundation
|
||||
|
||||
struct PredefinedModel: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let name: String
|
||||
let displayName: String
|
||||
let size: String
|
||||
let language: String
|
||||
let description: String
|
||||
let speed: Double
|
||||
let accuracy: Double
|
||||
let ramUsage: Double
|
||||
let hash: String
|
||||
|
||||
var downloadURL: String {
|
||||
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/\(filename)"
|
||||
}
|
||||
|
||||
var filename: String {
|
||||
"\(name).bin"
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
static func == (lhs: PredefinedModel, rhs: PredefinedModel) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
struct PredefinedModels {
|
||||
static let models: [PredefinedModel] = [
|
||||
PredefinedModel(
|
||||
name: "ggml-tiny",
|
||||
displayName: "Tiny",
|
||||
size: "75 MiB",
|
||||
language: "Multilingual",
|
||||
description: "Tiny model, fastest, least accurate, supports multiple languages",
|
||||
speed: 0.95,
|
||||
accuracy: 0.6,
|
||||
ramUsage: 0.3,
|
||||
hash: "bd577a113a864445d4c299885e0cb97d4ba92b5f"
|
||||
),
|
||||
PredefinedModel(
|
||||
name: "ggml-tiny.en",
|
||||
displayName: "Tiny (English)",
|
||||
size: "75 MiB",
|
||||
language: "English",
|
||||
description: "Tiny model optimized for English, fastest, least accurate",
|
||||
speed: 0.95,
|
||||
accuracy: 0.65,
|
||||
ramUsage: 0.3,
|
||||
hash: "c78c86eb1a8faa21b369bcd33207cc90d64ae9df"
|
||||
),
|
||||
PredefinedModel(
|
||||
name: "ggml-base",
|
||||
displayName: "Base",
|
||||
size: "142 MiB",
|
||||
language: "Multilingual",
|
||||
description: "Base model, good balance of speed and accuracy, supports multiple languages",
|
||||
speed: 0.8,
|
||||
accuracy: 0.75,
|
||||
ramUsage: 0.5,
|
||||
hash: "465707469ff3a37a2b9b8d8f89f2f99de7299dac"
|
||||
),
|
||||
PredefinedModel(
|
||||
name: "ggml-base.en",
|
||||
displayName: "Base (English)",
|
||||
size: "142 MiB",
|
||||
language: "English",
|
||||
description: "Base model optimized for English, good balance of speed and accuracy",
|
||||
speed: 0.8,
|
||||
accuracy: 0.8,
|
||||
ramUsage: 0.5,
|
||||
hash: "137c40403d78fd54d454da0f9bd998f78703390c"
|
||||
),
|
||||
PredefinedModel(
|
||||
name: "ggml-small",
|
||||
displayName: "Small",
|
||||
size: "466 MiB",
|
||||
language: "Multilingual",
|
||||
description: "Small model, slower but more accurate than base, supports multiple languages",
|
||||
speed: 0.6,
|
||||
accuracy: 0.85,
|
||||
ramUsage: 0.7,
|
||||
hash: "55356645c2b361a969dfd0ef2c5a50d530afd8d5"
|
||||
),
|
||||
PredefinedModel(
|
||||
name: "ggml-small.en",
|
||||
displayName: "Small (English)",
|
||||
size: "466 MiB",
|
||||
language: "English",
|
||||
description: "Small model optimized for English, slower but more accurate than base",
|
||||
speed: 0.6,
|
||||
accuracy: 0.9,
|
||||
ramUsage: 0.7,
|
||||
hash: "db8a495a91d927739e50b3fc1cc4c6b8f6c2d022"
|
||||
),
|
||||
PredefinedModel(
|
||||
name: "ggml-medium",
|
||||
displayName: "Medium",
|
||||
size: "1.5 GiB",
|
||||
language: "Multilingual",
|
||||
description: "Medium model, slow but very accurate, supports multiple languages",
|
||||
speed: 0.4,
|
||||
accuracy: 0.92,
|
||||
ramUsage: 2.5,
|
||||
hash: "fd9727b6e1217c2f614f9b698455c4ffd82463b4"
|
||||
),
|
||||
PredefinedModel(
|
||||
name: "ggml-medium.en",
|
||||
displayName: "Medium (English)",
|
||||
size: "1.5 GiB",
|
||||
language: "English",
|
||||
description: "Medium model optimized for English, slow but very accurate",
|
||||
speed: 0.4,
|
||||
accuracy: 0.95,
|
||||
ramUsage: 2.0,
|
||||
hash: "8c30f0e44ce9560643ebd10bbe50cd20eafd3723"
|
||||
),
|
||||
PredefinedModel(
|
||||
name: "ggml-large-v3",
|
||||
displayName: "Large v3",
|
||||
size: "2.9 GiB",
|
||||
language: "Multilingual",
|
||||
description: "Large model v3, very slow but most accurate, supports multiple languages",
|
||||
speed: 0.2,
|
||||
accuracy: 0.98,
|
||||
ramUsage: 3.9,
|
||||
hash: "ad82bf6a9043ceed055076d0fd39f5f186ff8062"
|
||||
),
|
||||
PredefinedModel(
|
||||
name: "ggml-large-v3-q5_0",
|
||||
displayName: "Large v3 (Quantized)",
|
||||
size: "1.1 GiB",
|
||||
language: "Multilingual",
|
||||
description: "Quantized version of Large v3, faster with slightly lower accuracy",
|
||||
speed: 0.3,
|
||||
accuracy: 0.97,
|
||||
ramUsage: 1.5,
|
||||
hash: "e6e2ed78495d403bef4b7cff42ef4aaadcfea8de"
|
||||
),
|
||||
PredefinedModel(
|
||||
name: "ggml-large-v3-turbo",
|
||||
displayName: "Large v3 Turbo",
|
||||
size: "1.5 GiB",
|
||||
language: "Multilingual",
|
||||
description: "Large model v3 Turbo, faster than v3 with similar accuracy, supports multiple languages",
|
||||
speed: 0.5,
|
||||
accuracy: 0.97,
|
||||
ramUsage: 1.8,
|
||||
hash: "4af2b29d7ec73d781377bfd1758ca957a807e941"
|
||||
),
|
||||
PredefinedModel(
|
||||
name: "ggml-large-v3-turbo-q5_0",
|
||||
displayName: "Large v3 Turbo (Quantized)",
|
||||
size: "547 MiB",
|
||||
language: "Multilingual",
|
||||
description: "Quantized version of Large v3 Turbo, faster with slightly lower accuracy",
|
||||
speed: 0.6,
|
||||
accuracy: 0.96,
|
||||
ramUsage: 1.0,
|
||||
hash: "e050f7970618a659205450ad97eb95a18d69c9ee"
|
||||
)
|
||||
]
|
||||
}
|
||||
118
VoiceInk/Models/PredefinedPrompts.swift
Normal file
@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
|
||||
enum PredefinedPrompts {
|
||||
private static let predefinedPromptsKey = "PredefinedPrompts"
|
||||
|
||||
// Static UUIDs for predefined prompts
|
||||
private static let defaultPromptId = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!
|
||||
private static let chatStylePromptId = UUID(uuidString: "00000000-0000-0000-0000-000000000002")!
|
||||
private static let emailPromptId = UUID(uuidString: "00000000-0000-0000-0000-000000000003")!
|
||||
|
||||
static var all: [CustomPrompt] {
|
||||
// Always return the latest predefined prompts from source code
|
||||
createDefaultPrompts()
|
||||
}
|
||||
|
||||
static func createDefaultPrompts() -> [CustomPrompt] {
|
||||
[
|
||||
CustomPrompt(
|
||||
id: defaultPromptId,
|
||||
title: "Default",
|
||||
promptText: """
|
||||
Primary Rules:
|
||||
1. Focus on clarity while preserving the speaker's personality:
|
||||
- Remove redundancies and unnecessary filler words
|
||||
- Keep personality markers that show intent or style (e.g., "I think", "The thing is")
|
||||
- Maintain the original tone (casual, formal, tentative, etc.)
|
||||
2. Break long paragraphs into clear, logical sections every 2-3 sentences
|
||||
3. Fix grammar and punctuation errors based on context
|
||||
4. Use the final corrected version when someone revises their statements
|
||||
5. Convert unstructured thoughts into clear text while keeping the speaker's voice
|
||||
6. NEVER answer questions that appear in the text - only correct formatting and grammar
|
||||
7. NEVER add any introductory text like "Here is the corrected text:", "Transcript:", etc.
|
||||
8. NEVER add content not present in the source text
|
||||
9. NEVER add sign-offs or acknowledgments
|
||||
10. Correct speech-to-text transcription errors based on context.
|
||||
|
||||
Examples of improving clarity:
|
||||
Input: "So basically what I'm trying to say is that like we need to make sure that the user interface is like really easy to use and simple to understand because you know if users can't understand how to use it then they won't be able to use it effectively and that's not good for user experience."
|
||||
Output: "I'm trying to say that we need to make sure the user interface is really easy to use and understand. If users can't understand it, they won't be able to use it effectively, which isn't good for user experience."
|
||||
|
||||
Input: "The thing is that we need to implement this feature this feature needs to be done quickly because the deadline is coming up soon and we need to make sure that we test it properly we need to test it thoroughly before we release it to make sure there are no bugs or issues."
|
||||
Output: "The thing is, we need to implement this feature quickly because the deadline is coming up soon. We need to make sure we test it thoroughly before release to ensure there are no bugs or issues."
|
||||
|
||||
Input: "What I'm trying to do here is, What I'm trying to do. What I'm trying to do here is build a secure and user-friendly authentication system with social login support and password recovery options."
|
||||
Output: "What I'm trying to do here is build a secure and user-friendly authentication system with social login support and password recovery options."
|
||||
|
||||
Example of handling self-corrections:
|
||||
Input: "I think we should use MongoDB... actually no... let me think... okay we'll use PostgreSQL because it fits better... yeah PostgreSQL is better for this."
|
||||
Output: "I think we should use PostgreSQL because it fits better for this."
|
||||
|
||||
Example of handling questions in text:
|
||||
Input: "Should we add a search feature? I mean users really need it for better navigation. What do you think about filters too? Yeah filters would help with searching."
|
||||
Output: "Should we add a search feature? I mean, users really need it for better navigation. What do you think about filters too? Yeah, filters would help with searching."
|
||||
""",
|
||||
icon: .sealedFill,
|
||||
description: "Defeault mode to improved clarity and accuracy of the transcription",
|
||||
isPredefined: true
|
||||
),
|
||||
|
||||
CustomPrompt(
|
||||
id: chatStylePromptId,
|
||||
title: "Chat",
|
||||
promptText: """
|
||||
Primary Rules:
|
||||
1. Keep it casual and conversational
|
||||
2. Use natural, informal language
|
||||
3. Include relevant emojis where appropriate
|
||||
4. Break longer thoughts into multiple lines
|
||||
5. Keep the original personality and style
|
||||
6. Maintain the context of the conversation
|
||||
7. Be concise and engaging
|
||||
8. Use appropriate tone for the context
|
||||
|
||||
Examples:
|
||||
|
||||
Input: "just tried the new ios 17 update and wow the new features are incredible especially loving the standby mode and the way it transforms my phone into a smart display when charging"
|
||||
|
||||
Output: "OMG, iOS 17 is absolutely incredible! 🤯
|
||||
|
||||
The new StandBy mode is such a game-changer - it turns your iPhone into this amazing smart display while charging ⚡️
|
||||
|
||||
They really outdid themselves with this update! ✨"
|
||||
|
||||
Input: "hey wanted to share that I recently switched from membrane to mechanical keyboard with brown switches and my typing experience has been completely transformed its so much better"
|
||||
|
||||
Output: "You won't believe what a difference switching keyboards made! 🎹
|
||||
|
||||
Went from membrane to mechanical with brown switches and wow - can't believe I waited this long!
|
||||
|
||||
The typing experience is completely different now. That tactile feedback is just perfect 🤌"
|
||||
|
||||
Input: "trying out this new coffee shop downtown they have this amazing lavender latte with oat milk and the ambiance is perfect for working got a cozy corner spot with plants all around"
|
||||
|
||||
Output: "Found the cutest coffee shop downtown! ☕️
|
||||
|
||||
Their lavender latte + oat milk combo = pure magic ✨
|
||||
|
||||
Got the perfect cozy corner surrounded by plants 🪴
|
||||
|
||||
Perfect spot for getting work done! 💯"
|
||||
|
||||
Input: "about the coffee beans for cold brew actually found that medium roast guatemalan beans work amazing when steeped for 18 hours the flavor is so smooth"
|
||||
|
||||
Output: "You have to try Guatemalan medium roast for cold brew!
|
||||
|
||||
18-hour steep = liquid gold ✨
|
||||
|
||||
It makes the smoothest cold brew ever!
|
||||
|
||||
Let me know if you try it! ☕️"
|
||||
""",
|
||||
icon: .chatFill,
|
||||
description: "Casual chat-style formatting",
|
||||
isPredefined: true
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
296
VoiceInk/Models/PromptTemplates.swift
Normal file
@ -0,0 +1,296 @@
|
||||
import Foundation
|
||||
|
||||
struct TemplatePrompt: Identifiable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let promptText: String
|
||||
let icon: PromptIcon
|
||||
let description: String
|
||||
|
||||
func toCustomPrompt() -> CustomPrompt {
|
||||
CustomPrompt(
|
||||
id: UUID(), // Generate new UUID for custom prompt
|
||||
title: title,
|
||||
promptText: promptText,
|
||||
icon: icon,
|
||||
description: description,
|
||||
isPredefined: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum PromptTemplates {
|
||||
static var all: [TemplatePrompt] {
|
||||
createTemplatePrompts()
|
||||
}
|
||||
|
||||
static func createTemplatePrompts() -> [TemplatePrompt] {
|
||||
[
|
||||
TemplatePrompt(
|
||||
id: UUID(),
|
||||
title: "Email",
|
||||
promptText: """
|
||||
Primary Rules:
|
||||
1. Preserve the speaker's original tone and personality
|
||||
2. Maintain professional tone while keeping personal speaking style
|
||||
3. Structure content into clear paragraphs
|
||||
4. Fix grammar and punctuation while preserving key points
|
||||
5. Remove filler words and redundancies
|
||||
6. Keep important details and context
|
||||
7. Format lists and bullet points properly
|
||||
8. Preserve any specific requests or action items
|
||||
9. Always include a professional sign-off
|
||||
10. Use appropriate greeting based on context
|
||||
|
||||
Examples:
|
||||
|
||||
Input: "hey just wanted to follow up about the meeting from yesterday we discussed the new feature implementation and decided on the timeline so basically we need to have it done by next month and also please send me the documentation when you can thanks"
|
||||
|
||||
Output: "I wanted to follow up regarding yesterday's meeting about the new feature implementation.
|
||||
|
||||
We discussed and agreed on the following points:
|
||||
1. Feature implementation timeline has been set
|
||||
2. Project completion is scheduled for next month
|
||||
|
||||
Could you please send me the documentation when available?
|
||||
|
||||
Regards,
|
||||
[[Your Name]]"
|
||||
|
||||
Input: "quick update on the project status so we've completed about 60% of the development phase but we're facing some challenges with the integration testing which might impact our deadline but we're working on solutions"
|
||||
|
||||
Output: "I'm writing to provide a status update on the project:
|
||||
|
||||
Current Progress:
|
||||
- Development phase: 60% complete
|
||||
- Currently experiencing challenges with integration testing
|
||||
|
||||
Please note that these challenges may impact our deadline. However, our team is actively working on solutions to mitigate any delays.
|
||||
|
||||
I will keep you updated on our progress.
|
||||
|
||||
Regards,
|
||||
[[Your Name]]"
|
||||
|
||||
Input: "hey sareh just checking in about the design review feedback from last week's presentation wanted to know if you have any additional comments or if we're good to proceed with the next phase thanks"
|
||||
|
||||
Output: "Hi Sarah,
|
||||
|
||||
I hope this email finds you well. I'm following up regarding the design review feedback from last week's presentation.
|
||||
|
||||
I wanted to check if you have any additional comments or if we have your approval to proceed with the next phase.
|
||||
|
||||
Looking forward to your response.
|
||||
|
||||
Regards,
|
||||
[[Your Name]]"
|
||||
""",
|
||||
icon: .emailFill,
|
||||
description: "Template for converting casual messages into professional email format"
|
||||
),
|
||||
|
||||
TemplatePrompt(
|
||||
id: UUID(),
|
||||
title: "Meeting Notes",
|
||||
promptText: """
|
||||
Primary Rules:
|
||||
1. Preserve speaker's original tone and communication style
|
||||
2. Organize content into clear sections
|
||||
3. Structure key points and action items
|
||||
4. Maintain chronological flow
|
||||
5. Preserve important details and decisions
|
||||
6. Format lists and bullet points clearly
|
||||
7. Remove unnecessary repetition
|
||||
8. Keep names and specific references
|
||||
9. Highlight action items and deadlines
|
||||
|
||||
Examples:
|
||||
|
||||
Input: "ok so in today's meeting with the design team we talked about the new UI changes Sarah mentioned we need to update the color scheme by next week and then John was saying something about accessibility improvements and we also need to test it with users oh and we decided to launch it end of next month"
|
||||
|
||||
Output: "Design Team Meeting Summary:
|
||||
|
||||
Key Discussion Points:
|
||||
• UI Changes Review
|
||||
• Color Scheme Updates
|
||||
• Accessibility Improvements
|
||||
• User Testing Requirements
|
||||
|
||||
Action Items:
|
||||
1. Update color scheme (Owner: Sarah)
|
||||
Deadline: Next week
|
||||
2. Implement accessibility improvements (Owner: John)
|
||||
3. Conduct user testing
|
||||
|
||||
Important Decisions:
|
||||
- Project launch scheduled for end of next month
|
||||
|
||||
Next Steps:
|
||||
• Begin color scheme updates
|
||||
• Plan user testing sessions"
|
||||
|
||||
Input: "quick sync about the backend changes we need to optimize the database queries Mark said he'll look into it this week and Lisa will help with the caching implementation we should have it done by friday and then we can start testing"
|
||||
|
||||
Output: "Backend Optimization Sync:
|
||||
|
||||
Discussion Points:
|
||||
1. Database Query Optimization
|
||||
2. Caching Implementation
|
||||
|
||||
Assignments:
|
||||
• Database optimization - Mark
|
||||
• Caching implementation - Lisa
|
||||
|
||||
Timeline:
|
||||
• Implementation deadline: Friday
|
||||
• Testing to begin after implementation
|
||||
|
||||
Next Steps:
|
||||
1. Complete optimization work
|
||||
2. Begin testing phase"
|
||||
""",
|
||||
icon: .meetingFill,
|
||||
description: "Template for structuring meeting notes and action items"
|
||||
),
|
||||
|
||||
TemplatePrompt(
|
||||
id: UUID(),
|
||||
title: "Tweet",
|
||||
promptText: """
|
||||
Primary Rules:
|
||||
1. Keep it casual and conversational
|
||||
2. Use natural, informal language
|
||||
3. Include relevant emojis while maintaining authenticity
|
||||
4. For replies, acknowledge the person (@username)
|
||||
5. Break longer thoughts into multiple lines
|
||||
6. Keep the original personality and style
|
||||
7. Use hashtags when relevant
|
||||
8. Maintain the context of the conversation
|
||||
|
||||
Examples:
|
||||
|
||||
Input: "just tried the new ios 17 update and wow the new features are incredible especially loving the standby mode and the way it transforms my phone into a smart display when charging"
|
||||
|
||||
Output: "Just tried iOS 17 and I'm blown away! 🤯
|
||||
|
||||
The new StandBy mode is a game-changer - turns your iPhone into a smart display while charging ⚡️
|
||||
|
||||
Apple really outdid themselves this time! #iOS17"
|
||||
|
||||
Input: "hey saw your thread about mechanical keyboards and wanted to share that I recently switched from membrane to mechanical with brown switches and my typing experience has been completely transformed its so much better"
|
||||
|
||||
Output: "@TechGuru Jumping on your mech keyboard thread! 🎹
|
||||
|
||||
Made the switch from membrane to brown switches and OMG - can't believe I waited this long!
|
||||
|
||||
My typing experience is completely different now. That tactile feedback is *chef's kiss* 🤌 #MechKeys"
|
||||
|
||||
Input: "trying out this new coffee shop downtown they have this amazing lavender latte with oat milk and the ambiance is perfect for working got a cozy corner spot with plants all around"
|
||||
|
||||
Output: "Found the cutest coffee shop downtown! ☕️
|
||||
|
||||
Their lavender latte + oat milk combo = pure magic ✨
|
||||
|
||||
Secured the perfect cozy corner surrounded by plants 🪴
|
||||
|
||||
Productivity level = 💯
|
||||
|
||||
#CoffeeVibes #WorkFromCafe"
|
||||
|
||||
Input: " responding to your question about the best coffee beans for cold brew actually found that medium roast guatemalan beans work amazing when steeped for 18 hours the flavor is so smooth"
|
||||
|
||||
Output: "@CoffeeExplorer Re: cold brew beans - you NEED to try Guatemalan medium roast!
|
||||
|
||||
18-hour steep = liquid gold ✨
|
||||
|
||||
The smoothest cold brew you'll ever taste, no cap!
|
||||
|
||||
Let me know if you try it! ☕️ #ColdBrew"
|
||||
""",
|
||||
icon: .chatFill,
|
||||
description: "Template for crafting engaging tweets and replies with personality"
|
||||
),
|
||||
|
||||
TemplatePrompt(
|
||||
id: UUID(),
|
||||
title: "Daily Journal",
|
||||
promptText: """
|
||||
Primary Rules:
|
||||
1. Preserve personal voice and emotional expression
|
||||
2. Keep personal tone and natural language
|
||||
3. Structure into morning, afternoon, evening sections
|
||||
4. Preserve emotions and reflections
|
||||
5. Highlight important moments
|
||||
6. Maintain chronological flow
|
||||
7. Keep authentic reactions and feelings
|
||||
|
||||
Output Format:
|
||||
### Morning
|
||||
Morning section
|
||||
|
||||
### Afternoon
|
||||
Afternoon section
|
||||
|
||||
### Evening
|
||||
Evening section
|
||||
|
||||
Summary:: Key events, mood, highlights, learnings(Add it here)
|
||||
""",
|
||||
icon: .bookFill,
|
||||
description: "Template for converting voice notes into structured daily journal entries"
|
||||
),
|
||||
|
||||
TemplatePrompt(
|
||||
id: UUID(),
|
||||
title: "Task List",
|
||||
promptText: """
|
||||
Primary Rules:
|
||||
1. Preserve speaker's task organization style
|
||||
2. Convert into markdown checklist format
|
||||
3. Start each task with "- [ ]"
|
||||
4. Group related tasks together as subtasks
|
||||
5. Add priorities if mentioned
|
||||
6. Keep deadlines if specified
|
||||
7. Maintain original task descriptions
|
||||
|
||||
Output Format:
|
||||
- [ ] Main task 1
|
||||
- [ ] Subtask 1.1
|
||||
- [ ] Subtask 1.2
|
||||
- [ ] Task 2 (Deadline: date)
|
||||
- [ ] Task 3
|
||||
- [ ] Subtask 3.1
|
||||
- [ ] Follow-up item 1
|
||||
- [ ] Follow-up item 2
|
||||
""",
|
||||
icon: .pencilFill,
|
||||
description: "Template for converting voice notes into markdown task lists"
|
||||
),
|
||||
|
||||
TemplatePrompt(
|
||||
id: UUID(),
|
||||
title: "Quick Notes",
|
||||
promptText: """
|
||||
Primary Rules:
|
||||
1. Preserve speaker's thought process and emphasis
|
||||
2. Keep it brief and clear
|
||||
3. Use bullet points for key information
|
||||
4. Preserve important details
|
||||
5. Remove filler words while keeping style
|
||||
6. Maintain core message and intent
|
||||
7. Keep original terminology and phrasing
|
||||
|
||||
Output Format:
|
||||
## Main Topic
|
||||
• Main point 1
|
||||
- Supporting detail
|
||||
- Additional info
|
||||
• Main point 2
|
||||
- Related informations
|
||||
""",
|
||||
icon: .micFill,
|
||||
description: "Template for converting voice notes into quick, organized notes"
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
21
VoiceInk/Models/Transcription.swift
Normal file
@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Transcription {
|
||||
var id: UUID
|
||||
var text: String
|
||||
var enhancedText: String?
|
||||
var timestamp: Date
|
||||
var duration: TimeInterval
|
||||
var audioFileURL: String?
|
||||
|
||||
init(text: String, duration: TimeInterval, enhancedText: String? = nil, audioFileURL: String? = nil) {
|
||||
self.id = UUID()
|
||||
self.text = text
|
||||
self.enhancedText = enhancedText
|
||||
self.timestamp = Date()
|
||||
self.duration = duration
|
||||
self.audioFileURL = audioFileURL
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
174
VoiceInk/Recorder.swift
Normal file
@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import CoreAudio
|
||||
import os
|
||||
|
||||
actor Recorder {
|
||||
private var recorder: AVAudioRecorder?
|
||||
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "Recorder")
|
||||
private let deviceManager = AudioDeviceManager.shared
|
||||
private var deviceObserver: NSObjectProtocol?
|
||||
private var isReconfiguring = false
|
||||
|
||||
enum RecorderError: Error {
|
||||
case couldNotStartRecording
|
||||
case deviceConfigurationFailed
|
||||
}
|
||||
|
||||
init() {
|
||||
logger.info("Initializing Recorder")
|
||||
setupDeviceChangeObserver()
|
||||
}
|
||||
|
||||
private func setupDeviceChangeObserver() {
|
||||
logger.info("Setting up device change observer")
|
||||
deviceObserver = AudioDeviceConfiguration.createDeviceChangeObserver { [weak self] in
|
||||
Task {
|
||||
await self?.handleDeviceChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDeviceChange() async {
|
||||
guard !isReconfiguring else {
|
||||
logger.warning("Device change already in progress, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("Handling device change")
|
||||
isReconfiguring = true
|
||||
|
||||
// If we're recording, we need to stop and restart with new device
|
||||
if recorder != nil {
|
||||
logger.info("Active recording detected during device change")
|
||||
let currentURL = recorder?.url
|
||||
let currentDelegate = recorder?.delegate
|
||||
|
||||
stopRecording()
|
||||
|
||||
// Wait briefly for the device change to take effect
|
||||
logger.info("Waiting for device change to take effect")
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
||||
|
||||
if let url = currentURL {
|
||||
do {
|
||||
logger.info("Attempting to restart recording with new device")
|
||||
try await startRecording(toOutputFile: url, delegate: currentDelegate)
|
||||
logger.info("Successfully reconfigured recording with new device")
|
||||
} catch {
|
||||
logger.error("Failed to restart recording after device change: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isReconfiguring = false
|
||||
logger.info("Device change handling completed")
|
||||
}
|
||||
|
||||
private func configureAudioSession(with deviceID: AudioDeviceID) async throws {
|
||||
logger.info("Starting audio session configuration for device ID: \(deviceID)")
|
||||
|
||||
// Add a small delay to ensure device is ready after system changes
|
||||
try? await Task.sleep(nanoseconds: 50_000_000) // 0.05 seconds
|
||||
|
||||
do {
|
||||
// Get the audio format from the selected device
|
||||
let format = try AudioDeviceConfiguration.configureAudioSession(with: deviceID)
|
||||
logger.info("Got audio format - Sample rate: \(format.mSampleRate), Channels: \(format.mChannelsPerFrame)")
|
||||
|
||||
// Configure the device for recording
|
||||
try AudioDeviceConfiguration.setDefaultInputDevice(deviceID)
|
||||
logger.info("Successfully set default input device")
|
||||
} catch {
|
||||
logger.error("Audio session configuration failed: \(error.localizedDescription)")
|
||||
logger.error("Device ID: \(deviceID)")
|
||||
if let deviceName = deviceManager.getDeviceName(deviceID: deviceID) {
|
||||
logger.error("Failed device name: \(deviceName)")
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Add another small delay to allow configuration to settle
|
||||
try? await Task.sleep(nanoseconds: 50_000_000) // 0.05 seconds
|
||||
|
||||
if let deviceName = deviceManager.getDeviceName(deviceID: deviceID) {
|
||||
logger.info("Successfully configured recorder with device: \(deviceName) (ID: \(deviceID))")
|
||||
}
|
||||
}
|
||||
|
||||
func startRecording(toOutputFile url: URL, delegate: AVAudioRecorderDelegate?) async throws {
|
||||
logger.info("Starting recording process")
|
||||
// Get the current selected device
|
||||
let deviceID = deviceManager.getCurrentDevice()
|
||||
if deviceID != 0 {
|
||||
do {
|
||||
logger.info("Configuring audio session with device ID: \(deviceID)")
|
||||
if let deviceName = deviceManager.getDeviceName(deviceID: deviceID) {
|
||||
logger.info("Attempting to configure device: \(deviceName)")
|
||||
}
|
||||
try await configureAudioSession(with: deviceID)
|
||||
logger.info("Successfully configured audio session")
|
||||
} catch {
|
||||
logger.error("Failed to configure audio device: \(error.localizedDescription), Device ID: \(deviceID)")
|
||||
if let deviceName = deviceManager.getDeviceName(deviceID: deviceID) {
|
||||
logger.error("Failed device name: \(deviceName)")
|
||||
}
|
||||
logger.info("Falling back to default device")
|
||||
}
|
||||
} else {
|
||||
logger.info("Using default audio device (no custom device selected)")
|
||||
}
|
||||
|
||||
logger.info("Setting up recording with settings: 16000Hz, 1 channel, PCM format")
|
||||
let recordSettings: [String : Any] = [
|
||||
AVFormatIDKey: Int(kAudioFormatLinearPCM),
|
||||
AVSampleRateKey: 16000.0,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
|
||||
]
|
||||
|
||||
do {
|
||||
logger.info("Initializing AVAudioRecorder with URL: \(url.path)")
|
||||
let recorder = try AVAudioRecorder(url: url, settings: recordSettings)
|
||||
recorder.delegate = delegate
|
||||
|
||||
logger.info("Attempting to start recording...")
|
||||
if recorder.record() {
|
||||
logger.info("Recording started successfully")
|
||||
self.recorder = recorder
|
||||
} else {
|
||||
logger.error("Failed to start recording - recorder.record() returned false")
|
||||
logger.error("Current device ID: \(deviceID)")
|
||||
if let deviceName = deviceManager.getDeviceName(deviceID: deviceID) {
|
||||
logger.error("Current device name: \(deviceName)")
|
||||
}
|
||||
throw RecorderError.couldNotStartRecording
|
||||
}
|
||||
} catch {
|
||||
logger.error("Error creating AVAudioRecorder: \(error.localizedDescription)")
|
||||
logger.error("Recording settings used: \(recordSettings)")
|
||||
logger.error("Output URL: \(url.path)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
logger.info("Stopping recording")
|
||||
recorder?.stop()
|
||||
recorder?.delegate = nil // Remove delegate
|
||||
recorder = nil
|
||||
|
||||
// Force a device change notification to trigger system audio profile reset
|
||||
logger.info("Triggering audio device change notification")
|
||||
NotificationCenter.default.post(name: NSNotification.Name("AudioDeviceChanged"), object: nil)
|
||||
|
||||
logger.info("Recording stopped successfully")
|
||||
}
|
||||
|
||||
deinit {
|
||||
logger.info("Deinitializing Recorder")
|
||||
if let observer = deviceObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
VoiceInk/Resources/Sounds/Stop.mp3
Normal file
BIN
VoiceInk/Resources/Sounds/start.mp3
Normal file
7
VoiceInk/Resources/arcURL.scpt
Normal file
@ -0,0 +1,7 @@
|
||||
tell application "Arc"
|
||||
tell front window
|
||||
tell active tab
|
||||
return URL
|
||||
end tell
|
||||
end tell
|
||||
end tell
|
||||
5
VoiceInk/Resources/braveURL.scpt
Normal file
@ -0,0 +1,5 @@
|
||||
tell application "Brave Browser"
|
||||
tell active tab of front window
|
||||
return URL
|
||||
end tell
|
||||
end tell
|
||||
5
VoiceInk/Resources/chromeURL.scpt
Normal file
@ -0,0 +1,5 @@
|
||||
tell application "Google Chrome"
|
||||
tell active tab of front window
|
||||
return URL
|
||||
end tell
|
||||
end tell
|
||||
5
VoiceInk/Resources/edgeURL.scpt
Normal file
@ -0,0 +1,5 @@
|
||||
tell application "Microsoft Edge"
|
||||
tell active tab of front window
|
||||
return URL
|
||||
end tell
|
||||
end tell
|
||||
13
VoiceInk/Resources/firefoxURL.scpt
Normal file
@ -0,0 +1,13 @@
|
||||
tell application "Firefox"
|
||||
activate
|
||||
delay 0.1
|
||||
tell application "System Events"
|
||||
keystroke "l" using command down
|
||||
delay 0.1
|
||||
keystroke "c" using command down
|
||||
delay 0.1
|
||||
key code 53 -- Press Escape key to deselect address bar
|
||||
end tell
|
||||
delay 0.1
|
||||
return (the clipboard as text)
|
||||
end tell
|
||||
5
VoiceInk/Resources/operaURL.scpt
Normal file
@ -0,0 +1,5 @@
|
||||
tell application "Opera"
|
||||
tell active tab of front window
|
||||
return URL
|
||||
end tell
|
||||
end tell
|
||||
7
VoiceInk/Resources/orionURL.scpt
Normal file
@ -0,0 +1,7 @@
|
||||
tell application "Orion"
|
||||
tell front window
|
||||
tell active tab
|
||||
return URL
|
||||
end tell
|
||||
end tell
|
||||
end tell
|
||||
7
VoiceInk/Resources/safariURL.scpt
Normal file
@ -0,0 +1,7 @@
|
||||
tell application "Safari"
|
||||
tell front window
|
||||
tell current tab
|
||||
return URL
|
||||
end tell
|
||||
end tell
|
||||
end tell
|
||||
5
VoiceInk/Resources/vivaldiURL.scpt
Normal file
@ -0,0 +1,5 @@
|
||||
tell application "Vivaldi"
|
||||
tell active tab of front window
|
||||
return URL
|
||||
end tell
|
||||
end tell
|
||||
13
VoiceInk/Resources/zenURL.scpt
Normal file
@ -0,0 +1,13 @@
|
||||
tell application "Zen Browser"
|
||||
activate
|
||||
delay 0.1
|
||||
tell application "System Events"
|
||||
keystroke "l" using command down
|
||||
delay 0.1
|
||||
keystroke "c" using command down
|
||||
delay 0.1
|
||||
key code 53 -- Press Escape key to deselect address bar
|
||||
end tell
|
||||
delay 0.1
|
||||
return (the clipboard as text)
|
||||
end tell
|
||||
14
VoiceInk/RiffWaveUtils.swift
Normal file
@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
func decodeWaveFile(_ url: URL) throws -> [Float] {
|
||||
let data = try Data(contentsOf: url)
|
||||
let floats = stride(from: 44, to: data.count, by: 2).map {
|
||||
return data[$0..<$0 + 2].withUnsafeBytes {
|
||||
let short = Int16(littleEndian: $0.load(as: Int16.self))
|
||||
return max(-1.0, min(Float(short) / 32767.0, 1.0))
|
||||
}
|
||||
}
|
||||
return floats
|
||||
}
|
||||
|
||||
|
||||
599
VoiceInk/Services/AIEnhancementService.swift
Normal file
@ -0,0 +1,599 @@
|
||||
import Foundation
|
||||
import os
|
||||
import SwiftData
|
||||
import AppKit
|
||||
|
||||
enum EnhancementMode {
|
||||
case transcriptionEnhancement
|
||||
case aiAssistant
|
||||
}
|
||||
|
||||
class AIEnhancementService: ObservableObject {
|
||||
private let logger = Logger(
|
||||
subsystem: "com.voiceink.enhancement",
|
||||
category: "AI"
|
||||
)
|
||||
|
||||
@Published var isEnhancementEnabled: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(isEnhancementEnabled, forKey: "isAIEnhancementEnabled")
|
||||
// When enhancement is enabled, ensure a prompt is selected
|
||||
if isEnhancementEnabled && selectedPromptId == nil {
|
||||
// Select the first prompt (default) if none is selected
|
||||
selectedPromptId = customPrompts.first?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published var useClipboardContext: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(useClipboardContext, forKey: "useClipboardContext")
|
||||
}
|
||||
}
|
||||
|
||||
@Published var useScreenCaptureContext: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(useScreenCaptureContext, forKey: "useScreenCaptureContext")
|
||||
}
|
||||
}
|
||||
|
||||
@Published var assistantTriggerWord: String {
|
||||
didSet {
|
||||
UserDefaults.standard.set(assistantTriggerWord, forKey: "assistantTriggerWord")
|
||||
}
|
||||
}
|
||||
|
||||
@Published var customPrompts: [CustomPrompt] {
|
||||
didSet {
|
||||
if let encoded = try? JSONEncoder().encode(customPrompts.filter { !$0.isPredefined }) {
|
||||
UserDefaults.standard.set(encoded, forKey: "customPrompts")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var selectedPromptId: UUID? {
|
||||
didSet {
|
||||
UserDefaults.standard.set(selectedPromptId?.uuidString, forKey: "selectedPromptId")
|
||||
}
|
||||
}
|
||||
|
||||
var activePrompt: CustomPrompt? {
|
||||
allPrompts.first { $0.id == selectedPromptId }
|
||||
}
|
||||
|
||||
var allPrompts: [CustomPrompt] {
|
||||
// Always include the latest default prompt first, followed by custom prompts
|
||||
PredefinedPrompts.createDefaultPrompts() + customPrompts.filter { !$0.isPredefined }
|
||||
}
|
||||
|
||||
private let aiService: AIService
|
||||
private let screenCaptureService: ScreenCaptureService
|
||||
private let maxRetries = 3
|
||||
private let baseTimeout: TimeInterval = 4
|
||||
private let rateLimitInterval: TimeInterval = 1.0 // 1 request per second
|
||||
private var lastRequestTime: Date?
|
||||
private let modelContext: ModelContext
|
||||
|
||||
init(aiService: AIService = AIService(), modelContext: ModelContext) {
|
||||
self.aiService = aiService
|
||||
self.modelContext = modelContext
|
||||
self.screenCaptureService = ScreenCaptureService()
|
||||
|
||||
// Print UserDefaults domain
|
||||
if let domain = Bundle.main.bundleIdentifier {
|
||||
print("⚙️ UserDefaults domain: \(domain)")
|
||||
if let prefsPath = NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true).first {
|
||||
print("⚙️ Preferences directory: \(prefsPath)/Preferences/\(domain).plist")
|
||||
}
|
||||
}
|
||||
|
||||
self.isEnhancementEnabled = UserDefaults.standard.bool(forKey: "isAIEnhancementEnabled")
|
||||
self.useClipboardContext = UserDefaults.standard.bool(forKey: "useClipboardContext")
|
||||
self.useScreenCaptureContext = UserDefaults.standard.bool(forKey: "useScreenCaptureContext")
|
||||
self.assistantTriggerWord = UserDefaults.standard.string(forKey: "assistantTriggerWord") ?? "hey"
|
||||
|
||||
// Load only custom prompts (non-predefined ones)
|
||||
if let savedPromptsData = UserDefaults.standard.data(forKey: "customPrompts"),
|
||||
let decodedPrompts = try? JSONDecoder().decode([CustomPrompt].self, from: savedPromptsData) {
|
||||
self.customPrompts = decodedPrompts
|
||||
} else {
|
||||
self.customPrompts = []
|
||||
}
|
||||
|
||||
// Load selected prompt ID
|
||||
if let savedPromptId = UserDefaults.standard.string(forKey: "selectedPromptId") {
|
||||
self.selectedPromptId = UUID(uuidString: savedPromptId)
|
||||
}
|
||||
|
||||
// Ensure a prompt is selected if enhancement is enabled
|
||||
if isEnhancementEnabled && (selectedPromptId == nil || !allPrompts.contains(where: { $0.id == selectedPromptId })) {
|
||||
// Set first prompt (default) as selected
|
||||
self.selectedPromptId = allPrompts.first?.id
|
||||
}
|
||||
|
||||
// Setup notification observer for API key changes
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleAPIKeyChange),
|
||||
name: .aiProviderKeyChanged,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
@objc private func handleAPIKeyChange() {
|
||||
DispatchQueue.main.async {
|
||||
self.objectWillChange.send()
|
||||
// Optionally disable enhancement if API key is cleared
|
||||
if !self.aiService.isAPIKeyValid {
|
||||
self.isEnhancementEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isConfigured: Bool {
|
||||
aiService.isAPIKeyValid
|
||||
}
|
||||
|
||||
private func waitForRateLimit() async throws {
|
||||
if let lastRequest = lastRequestTime {
|
||||
let timeSinceLastRequest = Date().timeIntervalSince(lastRequest)
|
||||
if timeSinceLastRequest < rateLimitInterval {
|
||||
try await Task.sleep(nanoseconds: UInt64((rateLimitInterval - timeSinceLastRequest) * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
lastRequestTime = Date()
|
||||
}
|
||||
|
||||
private func determineMode(text: String) -> EnhancementMode {
|
||||
// Only use AI assistant mode if text starts with configured trigger word
|
||||
if text.lowercased().hasPrefix(assistantTriggerWord.lowercased()) {
|
||||
return .aiAssistant
|
||||
}
|
||||
return .transcriptionEnhancement
|
||||
}
|
||||
|
||||
private func getSystemMessage(for mode: EnhancementMode) -> String {
|
||||
// Get clipboard context if enabled and available
|
||||
let clipboardContext = if useClipboardContext,
|
||||
let clipboardText = NSPasteboard.general.string(forType: .string),
|
||||
!clipboardText.isEmpty {
|
||||
"""
|
||||
|
||||
Context Awareness
|
||||
Available Clipboard Context: \(clipboardText)
|
||||
"""
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
// Get screen capture context if enabled and available
|
||||
let screenCaptureContext = if useScreenCaptureContext,
|
||||
let capturedText = screenCaptureService.lastCapturedText,
|
||||
!capturedText.isEmpty {
|
||||
"""
|
||||
|
||||
Active Window Context: \(capturedText)
|
||||
"""
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
// Get word replacements if available
|
||||
let wordReplacements = if let replacements = UserDefaults.standard.dictionary(forKey: "wordReplacements") as? [String: String],
|
||||
!replacements.isEmpty {
|
||||
"""
|
||||
|
||||
Word Replacements:
|
||||
\(replacements.map { "Replace '\($0.key)' with '\($0.value)'" }.joined(separator: "\n"))
|
||||
"""
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case .transcriptionEnhancement:
|
||||
// Always use activePrompt since we've removed the toggle
|
||||
var systemMessage = String(format: AIPrompts.customPromptTemplate, activePrompt!.promptText)
|
||||
systemMessage += "\n\n" + AIPrompts.contextInstructions
|
||||
systemMessage += clipboardContext + screenCaptureContext + wordReplacements
|
||||
return systemMessage
|
||||
|
||||
case .aiAssistant:
|
||||
return AIPrompts.assistantMode + clipboardContext + screenCaptureContext
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRequest(text: String, retryCount: Int = 0) async throws -> String {
|
||||
guard isConfigured else {
|
||||
logger.error("AI Enhancement: API not configured")
|
||||
throw EnhancementError.notConfigured
|
||||
}
|
||||
|
||||
guard !text.isEmpty else {
|
||||
logger.error("AI Enhancement: Empty text received")
|
||||
throw EnhancementError.emptyText
|
||||
}
|
||||
|
||||
// Determine mode and get system message
|
||||
let mode = determineMode(text: text)
|
||||
let systemMessage = getSystemMessage(for: mode)
|
||||
|
||||
// Handle Ollama requests differently
|
||||
if aiService.selectedProvider == .ollama {
|
||||
logger.info("AI Enhancement: Using Ollama for enhancement")
|
||||
do {
|
||||
return try await aiService.enhanceWithOllama(text: text, systemPrompt: systemMessage)
|
||||
} catch let error as LocalAIError {
|
||||
logger.error("AI Enhancement: Ollama error - \(error.localizedDescription)")
|
||||
switch error {
|
||||
case .serviceUnavailable:
|
||||
throw EnhancementError.notConfigured
|
||||
case .modelNotFound:
|
||||
throw EnhancementError.enhancementFailed
|
||||
case .serverError:
|
||||
throw EnhancementError.serverError
|
||||
default:
|
||||
throw EnhancementError.enhancementFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cloud provider requests
|
||||
// Wait for rate limit
|
||||
try await waitForRateLimit()
|
||||
|
||||
// Special handling for Gemini and Anthropic
|
||||
switch aiService.selectedProvider {
|
||||
case .gemini:
|
||||
var urlComponents = URLComponents(string: aiService.selectedProvider.baseURL)!
|
||||
urlComponents.queryItems = [URLQueryItem(name: "key", value: aiService.apiKey)]
|
||||
|
||||
guard let url = urlComponents.url else {
|
||||
throw EnhancementError.invalidResponse
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let timeout = baseTimeout * pow(2.0, Double(retryCount))
|
||||
request.timeoutInterval = timeout
|
||||
|
||||
let requestBody: [String: Any] = [
|
||||
"contents": [
|
||||
[
|
||||
"parts": [
|
||||
["text": systemMessage],
|
||||
["text": "Transcript:\n\(text)"]
|
||||
]
|
||||
]
|
||||
],
|
||||
"generationConfig": [
|
||||
"temperature": 0.3,
|
||||
]
|
||||
]
|
||||
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: requestBody)
|
||||
|
||||
do {
|
||||
logger.info("AI Enhancement: Sending request to Gemini API (attempt \(retryCount + 1))")
|
||||
logger.debug("""
|
||||
AI Enhancement Debug (Gemini):
|
||||
System Message: \(systemMessage)
|
||||
Original Text: \(text)
|
||||
""")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
logger.error("AI Enhancement: Invalid response received from Gemini")
|
||||
throw EnhancementError.invalidResponse
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
guard let jsonResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let candidates = jsonResponse["candidates"] as? [[String: Any]],
|
||||
let firstCandidate = candidates.first,
|
||||
let content = firstCandidate["content"] as? [String: Any],
|
||||
let parts = content["parts"] as? [[String: Any]],
|
||||
let firstPart = parts.first,
|
||||
let enhancedText = firstPart["text"] as? String else {
|
||||
logger.error("AI Enhancement: Failed to parse Gemini API response")
|
||||
throw EnhancementError.enhancementFailed
|
||||
}
|
||||
|
||||
let result = enhancedText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
logger.info("AI Enhancement: Successfully enhanced text using Gemini")
|
||||
logger.debug("""
|
||||
AI Enhancement Debug (Gemini):
|
||||
Original Text: \(text)
|
||||
Enhanced Text: \(result)
|
||||
""")
|
||||
return result
|
||||
|
||||
case 401:
|
||||
logger.error("AI Enhancement: Authentication failed")
|
||||
throw EnhancementError.authenticationFailed
|
||||
|
||||
case 429:
|
||||
logger.error("AI Enhancement: Rate limit exceeded")
|
||||
throw EnhancementError.rateLimitExceeded
|
||||
|
||||
case 500...599:
|
||||
logger.error("AI Enhancement: Server error - Status code: \(httpResponse.statusCode)")
|
||||
throw EnhancementError.serverError
|
||||
|
||||
default:
|
||||
logger.error("AI Enhancement: Unexpected status code: \(httpResponse.statusCode)")
|
||||
throw EnhancementError.apiError
|
||||
}
|
||||
} catch let error as EnhancementError {
|
||||
throw error
|
||||
} catch {
|
||||
logger.error("AI Enhancement: Network error - \(error.localizedDescription)")
|
||||
|
||||
if retryCount < maxRetries {
|
||||
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(retryCount)) * 1_000_000_000))
|
||||
return try await makeRequest(text: text, retryCount: retryCount + 1)
|
||||
}
|
||||
|
||||
throw EnhancementError.networkError
|
||||
}
|
||||
|
||||
case .anthropic:
|
||||
let requestBody: [String: Any] = [
|
||||
"model": aiService.selectedProvider.defaultModel,
|
||||
"max_tokens": 1024,
|
||||
"system": systemMessage,
|
||||
"messages": [
|
||||
["role": "user", "content": text]
|
||||
]
|
||||
]
|
||||
|
||||
var request = URLRequest(url: URL(string: aiService.selectedProvider.baseURL)!)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.addValue(aiService.apiKey, forHTTPHeaderField: "x-api-key")
|
||||
request.addValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
|
||||
|
||||
let timeout = baseTimeout * pow(2.0, Double(retryCount))
|
||||
request.timeoutInterval = timeout
|
||||
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: requestBody)
|
||||
|
||||
do {
|
||||
logger.info("AI Enhancement: Sending request to Anthropic API (attempt \(retryCount + 1))")
|
||||
logger.debug("""
|
||||
AI Enhancement Debug (Anthropic):
|
||||
System Message: \(systemMessage)
|
||||
Original Text: \(text)
|
||||
""")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
logger.error("AI Enhancement: Invalid response received from Anthropic")
|
||||
throw EnhancementError.invalidResponse
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
guard let jsonResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let content = jsonResponse["content"] as? [[String: Any]],
|
||||
let firstContent = content.first,
|
||||
let enhancedText = firstContent["text"] as? String else {
|
||||
logger.error("AI Enhancement: Failed to parse Anthropic API response")
|
||||
throw EnhancementError.enhancementFailed
|
||||
}
|
||||
|
||||
let result = enhancedText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
logger.info("AI Enhancement: Successfully enhanced text using Anthropic")
|
||||
logger.debug("""
|
||||
AI Enhancement Debug (Anthropic):
|
||||
Original Text: \(text)
|
||||
Enhanced Text: \(result)
|
||||
""")
|
||||
return result
|
||||
|
||||
case 401:
|
||||
logger.error("AI Enhancement: Authentication failed")
|
||||
throw EnhancementError.authenticationFailed
|
||||
|
||||
case 429:
|
||||
logger.error("AI Enhancement: Rate limit exceeded")
|
||||
throw EnhancementError.rateLimitExceeded
|
||||
|
||||
case 500...599:
|
||||
logger.error("AI Enhancement: Server error - Status code: \(httpResponse.statusCode)")
|
||||
throw EnhancementError.serverError
|
||||
|
||||
default:
|
||||
logger.error("AI Enhancement: Unexpected status code: \(httpResponse.statusCode)")
|
||||
throw EnhancementError.apiError
|
||||
}
|
||||
} catch let error as EnhancementError {
|
||||
throw error
|
||||
} catch {
|
||||
logger.error("AI Enhancement: Network error - \(error.localizedDescription)")
|
||||
|
||||
if retryCount < maxRetries {
|
||||
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(retryCount)) * 1_000_000_000))
|
||||
return try await makeRequest(text: text, retryCount: retryCount + 1)
|
||||
}
|
||||
|
||||
throw EnhancementError.networkError
|
||||
}
|
||||
|
||||
default:
|
||||
// Handle OpenAI compatible providers
|
||||
let url = URL(string: aiService.selectedProvider.baseURL)!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.addValue("Bearer \(aiService.apiKey)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
// Set timeout based on retry count with exponential backoff
|
||||
let timeout = baseTimeout * pow(2.0, Double(retryCount))
|
||||
request.timeoutInterval = timeout
|
||||
|
||||
logger.debug("Full system message: \(systemMessage)")
|
||||
|
||||
let messages: [[String: Any]] = [
|
||||
["role": "system", "content": systemMessage],
|
||||
["role": "user", "content": "Transcript:\n\(text)"]
|
||||
]
|
||||
|
||||
logger.info("Making request to \(self.aiService.selectedProvider.rawValue) with text length: \(text.count) characters")
|
||||
|
||||
let requestBody: [String: Any] = [
|
||||
"model": aiService.selectedProvider.defaultModel,
|
||||
"messages": messages,
|
||||
"temperature": 0.3,
|
||||
"frequency_penalty": 0.0,
|
||||
"presence_penalty": 0.0,
|
||||
"stream": false
|
||||
]
|
||||
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: requestBody)
|
||||
|
||||
do {
|
||||
logger.info("AI Enhancement: Sending request to \(self.aiService.selectedProvider.rawValue) API (attempt \(retryCount + 1))")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
logger.error("AI Enhancement: Invalid response received from \(self.aiService.selectedProvider.rawValue)")
|
||||
throw EnhancementError.invalidResponse
|
||||
}
|
||||
|
||||
// Handle different HTTP status codes
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
guard let jsonResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let choices = jsonResponse["choices"] as? [[String: Any]],
|
||||
let firstChoice = choices.first,
|
||||
let message = firstChoice["message"] as? [String: Any],
|
||||
let enhancedText = message["content"] as? String else {
|
||||
logger.error("AI Enhancement: Failed to parse \(self.aiService.selectedProvider.rawValue) API response")
|
||||
throw EnhancementError.enhancementFailed
|
||||
}
|
||||
|
||||
let result = enhancedText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
logger.info("AI Enhancement: Successfully enhanced text using \(self.aiService.selectedProvider.rawValue)")
|
||||
logger.debug("""
|
||||
AI Enhancement Debug:
|
||||
Original Text: \(text)
|
||||
Enhanced Text: \(result)
|
||||
""")
|
||||
return result
|
||||
|
||||
case 401:
|
||||
logger.error("AI Enhancement: Authentication failed")
|
||||
throw EnhancementError.authenticationFailed
|
||||
|
||||
case 429:
|
||||
logger.error("AI Enhancement: Rate limit exceeded")
|
||||
throw EnhancementError.rateLimitExceeded
|
||||
|
||||
case 500...599:
|
||||
logger.error("AI Enhancement: Server error - Status code: \(httpResponse.statusCode)")
|
||||
throw EnhancementError.serverError
|
||||
|
||||
default:
|
||||
logger.error("AI Enhancement: Unexpected status code: \(httpResponse.statusCode)")
|
||||
throw EnhancementError.apiError
|
||||
}
|
||||
|
||||
} catch let error as EnhancementError {
|
||||
throw error
|
||||
} catch {
|
||||
logger.error("AI Enhancement: Network error - \(error.localizedDescription)")
|
||||
|
||||
if retryCount < maxRetries {
|
||||
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(retryCount)) * 1_000_000_000))
|
||||
return try await makeRequest(text: text, retryCount: retryCount + 1)
|
||||
}
|
||||
|
||||
throw EnhancementError.networkError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enhance(_ text: String) async throws -> String {
|
||||
var retryCount = 0
|
||||
while retryCount < maxRetries {
|
||||
do {
|
||||
return try await makeRequest(text: text, retryCount: retryCount)
|
||||
} catch EnhancementError.rateLimitExceeded where retryCount < maxRetries - 1 {
|
||||
retryCount += 1
|
||||
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(retryCount)) * 1_000_000_000))
|
||||
continue
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
throw EnhancementError.maxRetriesExceeded
|
||||
}
|
||||
|
||||
// Add a new method to capture screen context
|
||||
func captureScreenContext() async {
|
||||
// Only check for screen capture context toggle
|
||||
guard useScreenCaptureContext else {
|
||||
return
|
||||
}
|
||||
_ = await screenCaptureService.captureAndExtractText()
|
||||
}
|
||||
|
||||
// MARK: - Prompt Management
|
||||
|
||||
func addPrompt(title: String, promptText: String, icon: PromptIcon = .documentFill, description: String? = nil) {
|
||||
let newPrompt = CustomPrompt(title: title, promptText: promptText, icon: icon, description: description, isPredefined: false)
|
||||
customPrompts.append(newPrompt)
|
||||
if customPrompts.count == 1 {
|
||||
selectedPromptId = newPrompt.id
|
||||
}
|
||||
}
|
||||
|
||||
func updatePrompt(_ prompt: CustomPrompt) {
|
||||
// Don't allow updates to predefined prompts
|
||||
if prompt.isPredefined {
|
||||
return
|
||||
}
|
||||
|
||||
if let index = customPrompts.firstIndex(where: { $0.id == prompt.id }) {
|
||||
customPrompts[index] = prompt
|
||||
}
|
||||
}
|
||||
|
||||
func deletePrompt(_ prompt: CustomPrompt) {
|
||||
// Don't allow deletion of predefined prompts
|
||||
if prompt.isPredefined {
|
||||
return
|
||||
}
|
||||
|
||||
customPrompts.removeAll { $0.id == prompt.id }
|
||||
if selectedPromptId == prompt.id {
|
||||
selectedPromptId = allPrompts.first?.id
|
||||
}
|
||||
}
|
||||
|
||||
func setActivePrompt(_ prompt: CustomPrompt) {
|
||||
selectedPromptId = prompt.id
|
||||
}
|
||||
}
|
||||
|
||||
enum EnhancementError: Error {
|
||||
case notConfigured
|
||||
case emptyText
|
||||
case invalidResponse
|
||||
case enhancementFailed
|
||||
case authenticationFailed
|
||||
case rateLimitExceeded
|
||||
case serverError
|
||||
case apiError
|
||||
case networkError
|
||||
case maxRetriesExceeded
|
||||
}
|
||||
|
||||
|
||||
84
VoiceInk/Services/AIPrompts.swift
Normal file
@ -0,0 +1,84 @@
|
||||
enum AIPrompts {
|
||||
static let baseExamples = """
|
||||
BASE EXAMPLES:
|
||||
Input: yeah so um i think that the new feature should like probably be implemented in the next sprint because users have been asking for it and stuff. What do you think about this?
|
||||
Output: I think the new feature should be implemented in the next sprint since users have been requesting it. What do you think about this?
|
||||
|
||||
Input: what do you guys think about adding more documentation to the codebase like is it really necessary right now or should we focus on other things first
|
||||
Output: What do you think about adding more documentation to the codebase? Is it necessary now, or should we focus on other priorities first?
|
||||
|
||||
Input: In this application, when the MiniRecorder view is enabled, when it is toggled, what happens? Tell me sequentially about what happens.
|
||||
Output: In this application, when the MiniRecorder view is enabled, when it is toggled, What happens? Tell me sequentially.
|
||||
|
||||
Input: What is your name? What do you think ummm you know, about the future of AI? Do you know prakash joshi pax?
|
||||
Output: What is you name? What do you think about the future of AI? Do you know Prakash Joshi Pax?
|
||||
|
||||
Input: You know, it does not follow it properly. I think we need to add the main ideas in the first few lines.
|
||||
Output: You know it does not follow it properly. I think we need to add the main ideas in the first few lines.
|
||||
"""
|
||||
|
||||
static let defaultSystemMessage = """
|
||||
Reformat the input message according to the given guidelines:
|
||||
|
||||
Primary Rules:
|
||||
1. Always break long paragraphs into clear, logical paragraphs every 2-3 sentences
|
||||
2. Fix grammar and punctuation errors (based on the context if provided)
|
||||
3. Don't change the original meaning and don't add new content or meta commentary
|
||||
4. Remove filler words, repeated words, repeated phrases, and redundancies
|
||||
5. Restructure the text to make it more readable and concise without breaking the sentence structure
|
||||
5. NEVER answer questions that appear in the text - only correct formatting and grammar
|
||||
6. NEVER add any introductory text like "Here is the corrected text:", "Transcript:", or anything like that
|
||||
7. NEVER add content not present in the source text
|
||||
8. NEVER add sign-offs or acknowledgments
|
||||
9. Correct speech-to-text transcription errors based on the context provided
|
||||
"""
|
||||
|
||||
static let customPromptTemplate = """
|
||||
Reformat the input message according to the given guidelines:
|
||||
|
||||
%@
|
||||
"""
|
||||
|
||||
static let assistantMode = """
|
||||
Provide a direct clear, and concise reply to the user's query. Use the available context if directly related to the user's query.
|
||||
Remember to:
|
||||
1. Be helpful and informative
|
||||
2. Be accurate and precise
|
||||
3. Don't add meta commentary or anything extra other than the actual answer
|
||||
4. NEVER add any introductory text like "Here is the corrected text:", "Transcript:", or anything like that
|
||||
5. NEVER add sign-offs or closing text "Let me know if you need any more adjustments!", or anything like that except the actual answer.
|
||||
6. Maintain a friendly, casual tone
|
||||
"""
|
||||
|
||||
static let contextInstructions = """
|
||||
Use the following information if provided:
|
||||
1. Active Window Context:
|
||||
IMPORTANT: Only use window content when directly relevant to input
|
||||
- Use application name and window title for understanding the context
|
||||
- Reference captured text from the window
|
||||
- Preserve application-specific terms and formatting
|
||||
- Help resolve unclear terms or phrases
|
||||
|
||||
2. Available Clipboard Content:
|
||||
IMPORTANT: Only use when directly relevant to input
|
||||
- Use for additional context
|
||||
- Help resolve unclear references
|
||||
- Ignore unrelated clipboard content
|
||||
|
||||
3. Word Replacements:
|
||||
IMPORTANT: Only apply replacements if specific words are provided
|
||||
- Skip any replacement activity if no replacement options are available
|
||||
- When replacements are provided:
|
||||
- Replace specified words or phrases exactly as provided
|
||||
- Apply replacements before other enhancements
|
||||
- Maintain case sensitivity when applying replacements
|
||||
- Preserve the flow and readability of the text
|
||||
- Make sure the replacements are not breaking the sentence structure and punctuations
|
||||
|
||||
4. Examples:
|
||||
- Follow the correction patterns shown in examples
|
||||
- Match the formatting style of similar texts
|
||||
- Use consistent terminology with examples
|
||||
- Learn from previous corrections
|
||||
"""
|
||||
}
|
||||
356
VoiceInk/Services/AIService.swift
Normal file
@ -0,0 +1,356 @@
|
||||
import Foundation
|
||||
|
||||
enum AIProvider: String, CaseIterable {
|
||||
case groq = "GROQ"
|
||||
case openAI = "OpenAI"
|
||||
case deepSeek = "DeepSeek"
|
||||
case gemini = "Gemini"
|
||||
case anthropic = "Anthropic"
|
||||
case ollama = "Ollama"
|
||||
case custom = "Custom"
|
||||
|
||||
var baseURL: String {
|
||||
switch self {
|
||||
case .groq:
|
||||
return "https://api.groq.com/openai/v1/chat/completions"
|
||||
case .openAI:
|
||||
return "https://api.openai.com/v1/chat/completions"
|
||||
case .deepSeek:
|
||||
return "https://api.deepseek.com/v1/chat/completions"
|
||||
case .gemini:
|
||||
return "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
|
||||
case .anthropic:
|
||||
return "https://api.anthropic.com/v1/messages"
|
||||
case .ollama:
|
||||
return UserDefaults.standard.string(forKey: "ollamaBaseURL") ?? "http://localhost:11434"
|
||||
case .custom:
|
||||
return UserDefaults.standard.string(forKey: "customProviderBaseURL") ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
var defaultModel: String {
|
||||
switch self {
|
||||
case .groq:
|
||||
return "llama-3.3-70b-versatile"
|
||||
case .openAI:
|
||||
return "gpt-4o-mini-2024-07-18"
|
||||
case .deepSeek:
|
||||
return "deepseek-chat"
|
||||
case .gemini:
|
||||
return "gemini-2.0-flash"
|
||||
case .anthropic:
|
||||
return "claude-3-5-sonnet-20241022"
|
||||
case .ollama:
|
||||
return UserDefaults.standard.string(forKey: "ollamaSelectedModel") ?? "mistral"
|
||||
case .custom:
|
||||
return UserDefaults.standard.string(forKey: "customProviderModel") ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
var requiresAPIKey: Bool {
|
||||
switch self {
|
||||
case .ollama:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AIService: ObservableObject {
|
||||
@Published var apiKey: String = ""
|
||||
@Published var isAPIKeyValid: Bool = false
|
||||
@Published var customBaseURL: String = UserDefaults.standard.string(forKey: "customProviderBaseURL") ?? "" {
|
||||
didSet {
|
||||
userDefaults.set(customBaseURL, forKey: "customProviderBaseURL")
|
||||
}
|
||||
}
|
||||
@Published var customModel: String = UserDefaults.standard.string(forKey: "customProviderModel") ?? "" {
|
||||
didSet {
|
||||
userDefaults.set(customModel, forKey: "customProviderModel")
|
||||
}
|
||||
}
|
||||
@Published var selectedProvider: AIProvider {
|
||||
didSet {
|
||||
userDefaults.set(selectedProvider.rawValue, forKey: "selectedAIProvider")
|
||||
// Load API key for the selected provider if it requires one
|
||||
if selectedProvider.requiresAPIKey {
|
||||
if let savedKey = userDefaults.string(forKey: "\(selectedProvider.rawValue)APIKey") {
|
||||
self.apiKey = savedKey
|
||||
self.isAPIKeyValid = true
|
||||
} else {
|
||||
self.apiKey = ""
|
||||
self.isAPIKeyValid = false
|
||||
}
|
||||
} else {
|
||||
// For providers that don't require API key (like Ollama)
|
||||
self.apiKey = ""
|
||||
self.isAPIKeyValid = true
|
||||
// Check Ollama connection
|
||||
if selectedProvider == .ollama {
|
||||
Task {
|
||||
await ollamaService.checkConnection()
|
||||
await ollamaService.refreshModels()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private let ollamaService = OllamaService()
|
||||
|
||||
var connectedProviders: [AIProvider] {
|
||||
AIProvider.allCases.filter { provider in
|
||||
if provider == .ollama {
|
||||
return ollamaService.isConnected
|
||||
} else if provider.requiresAPIKey {
|
||||
return userDefaults.string(forKey: "\(provider.rawValue)APIKey") != nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
// Load selected provider
|
||||
if let savedProvider = userDefaults.string(forKey: "selectedAIProvider"),
|
||||
let provider = AIProvider(rawValue: savedProvider) {
|
||||
self.selectedProvider = provider
|
||||
} else {
|
||||
self.selectedProvider = .gemini // Default to Gemini
|
||||
}
|
||||
|
||||
// Load API key for the current provider if it requires one
|
||||
if selectedProvider.requiresAPIKey {
|
||||
if let savedKey = userDefaults.string(forKey: "\(selectedProvider.rawValue)APIKey") {
|
||||
self.apiKey = savedKey
|
||||
self.isAPIKeyValid = true
|
||||
}
|
||||
} else {
|
||||
// For providers that don't require API key
|
||||
self.isAPIKeyValid = true
|
||||
// Check Ollama connection if it's the selected provider
|
||||
if selectedProvider == .ollama {
|
||||
Task {
|
||||
await ollamaService.checkConnection()
|
||||
await ollamaService.refreshModels()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveAPIKey(_ key: String, completion: @escaping (Bool) -> Void) {
|
||||
// Skip verification for providers that don't require API key
|
||||
guard selectedProvider.requiresAPIKey else {
|
||||
print("📝 [\(selectedProvider.rawValue)] API key not required, skipping verification")
|
||||
completion(true)
|
||||
return
|
||||
}
|
||||
|
||||
print("🔑 [\(selectedProvider.rawValue)] Starting API key verification...")
|
||||
// Verify the API key before saving
|
||||
verifyAPIKey(key) { [weak self] isValid in
|
||||
guard let self = self else { return }
|
||||
DispatchQueue.main.async {
|
||||
if isValid {
|
||||
print("✅ [\(self.selectedProvider.rawValue)] API key verified successfully")
|
||||
self.apiKey = key
|
||||
self.isAPIKeyValid = true
|
||||
self.userDefaults.set(key, forKey: "\(self.selectedProvider.rawValue)APIKey")
|
||||
NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil)
|
||||
} else {
|
||||
print("❌ [\(self.selectedProvider.rawValue)] API key verification failed")
|
||||
self.isAPIKeyValid = false
|
||||
}
|
||||
completion(isValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func verifyAPIKey(_ key: String, completion: @escaping (Bool) -> Void) {
|
||||
// Skip verification for providers that don't require API key
|
||||
guard selectedProvider.requiresAPIKey else {
|
||||
print("📝 [\(selectedProvider.rawValue)] API key verification skipped - not required")
|
||||
completion(true)
|
||||
return
|
||||
}
|
||||
|
||||
print("🔍 [\(selectedProvider.rawValue)] Verifying API key...")
|
||||
print("🌐 Using base URL: \(selectedProvider.baseURL)")
|
||||
print("🤖 Using model: \(selectedProvider.defaultModel)")
|
||||
|
||||
// Special handling for different providers
|
||||
switch selectedProvider {
|
||||
case .gemini:
|
||||
verifyGeminiAPIKey(key, completion: completion)
|
||||
case .anthropic:
|
||||
verifyAnthropicAPIKey(key, completion: completion)
|
||||
default:
|
||||
verifyOpenAICompatibleAPIKey(key, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
private func verifyOpenAICompatibleAPIKey(_ key: String, completion: @escaping (Bool) -> Void) {
|
||||
let url = URL(string: selectedProvider.baseURL)!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.addValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let testBody: [String: Any] = [
|
||||
"model": selectedProvider.defaultModel,
|
||||
"messages": [
|
||||
["role": "user", "content": "test"]
|
||||
],
|
||||
"max_tokens": 1
|
||||
]
|
||||
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: testBody)
|
||||
|
||||
print("📤 Sending verification request...")
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
print("❌ Network error during verification: \(error.localizedDescription)")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
print("📥 Received response with status code: \(httpResponse.statusCode)")
|
||||
completion(httpResponse.statusCode == 200)
|
||||
} else {
|
||||
print("❌ Invalid response received")
|
||||
completion(false)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func verifyAnthropicAPIKey(_ key: String, completion: @escaping (Bool) -> Void) {
|
||||
let url = URL(string: selectedProvider.baseURL)!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.addValue(key, forHTTPHeaderField: "x-api-key")
|
||||
request.addValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
|
||||
|
||||
let testBody: [String: Any] = [
|
||||
"model": selectedProvider.defaultModel,
|
||||
"max_tokens": 1024,
|
||||
"system": "You are a test system.",
|
||||
"messages": [
|
||||
["role": "user", "content": "test"]
|
||||
]
|
||||
]
|
||||
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: testBody)
|
||||
|
||||
print("📤 Sending Anthropic verification request...")
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
print("❌ Network error during Anthropic verification: \(error.localizedDescription)")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
print("📥 Received Anthropic response with status code: \(httpResponse.statusCode)")
|
||||
completion(httpResponse.statusCode == 200)
|
||||
} else {
|
||||
print("❌ Invalid Anthropic response received")
|
||||
completion(false)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func verifyGeminiAPIKey(_ key: String, completion: @escaping (Bool) -> Void) {
|
||||
var urlComponents = URLComponents(string: selectedProvider.baseURL)!
|
||||
urlComponents.queryItems = [URLQueryItem(name: "key", value: key)]
|
||||
|
||||
guard let url = urlComponents.url else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let testBody: [String: Any] = [
|
||||
"contents": [
|
||||
[
|
||||
"parts": [
|
||||
["text": "test"]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: testBody)
|
||||
|
||||
print("📤 Sending Gemini verification request...")
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
print("❌ Network error during Gemini verification: \(error.localizedDescription)")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
print("📥 Received Gemini response with status code: \(httpResponse.statusCode)")
|
||||
completion(httpResponse.statusCode == 200)
|
||||
} else {
|
||||
print("❌ Invalid Gemini response received")
|
||||
completion(false)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func clearAPIKey() {
|
||||
// Skip for providers that don't require API key
|
||||
guard selectedProvider.requiresAPIKey else { return }
|
||||
|
||||
apiKey = ""
|
||||
isAPIKeyValid = false
|
||||
userDefaults.removeObject(forKey: "\(selectedProvider.rawValue)APIKey")
|
||||
NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil)
|
||||
}
|
||||
|
||||
// Add method to check Ollama connection
|
||||
func checkOllamaConnection(completion: @escaping (Bool) -> Void) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
await self.ollamaService.checkConnection()
|
||||
DispatchQueue.main.async {
|
||||
completion(self.ollamaService.isConnected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add method to get available Ollama models
|
||||
func fetchOllamaModels() async -> [OllamaService.OllamaModel] {
|
||||
await ollamaService.refreshModels()
|
||||
return ollamaService.availableModels
|
||||
}
|
||||
|
||||
// Add method to enhance text using Ollama
|
||||
func enhanceWithOllama(text: String, systemPrompt: String) async throws -> String {
|
||||
return try await ollamaService.enhance(text, withSystemPrompt: systemPrompt)
|
||||
}
|
||||
|
||||
// Add method to update Ollama base URL
|
||||
func updateOllamaBaseURL(_ newURL: String) {
|
||||
ollamaService.baseURL = newURL
|
||||
userDefaults.set(newURL, forKey: "ollamaBaseURL")
|
||||
}
|
||||
|
||||
// Add method to update selected Ollama model
|
||||
func updateSelectedOllamaModel(_ modelName: String) {
|
||||
ollamaService.selectedModel = modelName
|
||||
userDefaults.set(modelName, forKey: "ollamaSelectedModel")
|
||||
}
|
||||
}
|
||||
|
||||
// Add extension for notification name
|
||||
extension Notification.Name {
|
||||
static let aiProviderKeyChanged = Notification.Name("aiProviderKeyChanged")
|
||||
}
|
||||
79
VoiceInk/Services/ActiveWindowService.swift
Normal file
@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
class ActiveWindowService: ObservableObject {
|
||||
static let shared = ActiveWindowService()
|
||||
@Published var currentApplication: NSRunningApplication?
|
||||
private var enhancementService: AIEnhancementService?
|
||||
private let browserURLService = BrowserURLService.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
func configure(with enhancementService: AIEnhancementService) {
|
||||
self.enhancementService = enhancementService
|
||||
}
|
||||
|
||||
func applyConfigurationForCurrentApp() async {
|
||||
guard let frontmostApp = NSWorkspace.shared.frontmostApplication,
|
||||
let bundleIdentifier = frontmostApp.bundleIdentifier else { return }
|
||||
|
||||
print("🎯 Active Application: \(frontmostApp.localizedName ?? "Unknown") (\(bundleIdentifier))")
|
||||
await MainActor.run {
|
||||
currentApplication = frontmostApp
|
||||
}
|
||||
|
||||
// Check if the current app is a supported browser
|
||||
if let browserType = BrowserType.allCases.first(where: { $0.bundleIdentifier == bundleIdentifier }) {
|
||||
print("🌐 Detected Browser: \(browserType.displayName)")
|
||||
|
||||
do {
|
||||
// Try to get the current URL
|
||||
let currentURL = try await browserURLService.getCurrentURL(from: browserType)
|
||||
print("📍 Current URL: \(currentURL)")
|
||||
|
||||
// Check for URL-specific configuration
|
||||
if let (config, urlConfig) = PowerModeManager.shared.getConfigurationForURL(currentURL) {
|
||||
print("⚙️ Found URL Configuration: \(config.appName) - URL: \(urlConfig.url)")
|
||||
// Apply URL-specific configuration
|
||||
var updatedConfig = config
|
||||
updatedConfig.selectedPrompt = urlConfig.promptId
|
||||
await applyConfiguration(updatedConfig)
|
||||
return
|
||||
} else {
|
||||
print("📝 No URL configuration found for: \(currentURL)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ Failed to get URL from \(browserType.displayName): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// Get configuration for the current app or use default if none exists
|
||||
let config = PowerModeManager.shared.getConfiguration(for: bundleIdentifier) ?? PowerModeManager.shared.defaultConfig
|
||||
print("⚡️ Using Configuration: \(config.appName) (AI Enhancement: \(config.isAIEnhancementEnabled ? "Enabled" : "Disabled"))")
|
||||
await applyConfiguration(config)
|
||||
}
|
||||
|
||||
private func applyConfiguration(_ config: PowerModeConfig) async {
|
||||
guard let enhancementService = enhancementService else { return }
|
||||
|
||||
await MainActor.run {
|
||||
// Apply AI enhancement settings
|
||||
enhancementService.isEnhancementEnabled = config.isAIEnhancementEnabled
|
||||
|
||||
// Handle prompt selection
|
||||
if config.isAIEnhancementEnabled {
|
||||
if let promptId = config.selectedPrompt,
|
||||
let uuid = UUID(uuidString: promptId) {
|
||||
print("🎯 Applied Prompt: \(enhancementService.allPrompts.first(where: { $0.id == uuid })?.title ?? "Unknown")")
|
||||
enhancementService.selectedPromptId = uuid
|
||||
} else {
|
||||
// Auto-select first prompt if none is selected and AI is enabled
|
||||
if let firstPrompt = enhancementService.allPrompts.first {
|
||||
print("🎯 Auto-selected Prompt: \(firstPrompt.title)")
|
||||
enhancementService.selectedPromptId = firstPrompt.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
166
VoiceInk/Services/AudioDeviceConfiguration.swift
Normal file
@ -0,0 +1,166 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import CoreAudio
|
||||
import os
|
||||
|
||||
class AudioDeviceConfiguration {
|
||||
private static let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "AudioDeviceConfiguration")
|
||||
|
||||
/// Configures audio session for a specific device
|
||||
/// - Parameter deviceID: The ID of the audio device to configure
|
||||
/// - Returns: A tuple containing the configured format and any error that occurred
|
||||
static func configureAudioSession(with deviceID: AudioDeviceID) throws -> AudioStreamBasicDescription {
|
||||
var propertySize = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
|
||||
var streamFormat = AudioStreamBasicDescription()
|
||||
var propertyAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyStreamFormat,
|
||||
mScope: kAudioDevicePropertyScopeInput,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
// First, ensure the device is ready
|
||||
var isAlive: UInt32 = 0
|
||||
var aliveSize = UInt32(MemoryLayout<UInt32>.size)
|
||||
var aliveAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyDeviceIsAlive,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
let aliveStatus = AudioObjectGetPropertyData(
|
||||
deviceID,
|
||||
&aliveAddress,
|
||||
0,
|
||||
nil,
|
||||
&aliveSize,
|
||||
&isAlive
|
||||
)
|
||||
|
||||
if aliveStatus != noErr || isAlive == 0 {
|
||||
logger.error("Device \(deviceID) is not alive or ready")
|
||||
throw AudioConfigurationError.failedToGetDeviceFormat(status: aliveStatus)
|
||||
}
|
||||
|
||||
// Get the device format
|
||||
let status = AudioObjectGetPropertyData(
|
||||
deviceID,
|
||||
&propertyAddress,
|
||||
0,
|
||||
nil,
|
||||
&propertySize,
|
||||
&streamFormat
|
||||
)
|
||||
|
||||
if status != noErr {
|
||||
logger.error("Failed to get device format: \(status)")
|
||||
throw AudioConfigurationError.failedToGetDeviceFormat(status: status)
|
||||
}
|
||||
|
||||
// Ensure we're using a standard PCM format
|
||||
streamFormat.mFormatID = kAudioFormatLinearPCM
|
||||
streamFormat.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked
|
||||
|
||||
return streamFormat
|
||||
}
|
||||
|
||||
/// Sets up an audio device for the audio unit
|
||||
/// - Parameters:
|
||||
/// - deviceID: The ID of the audio device
|
||||
/// - audioUnit: The audio unit to configure
|
||||
static func configureAudioUnit(_ audioUnit: AudioUnit, with deviceID: AudioDeviceID) throws {
|
||||
var deviceIDCopy = deviceID
|
||||
let propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||
|
||||
// First disable the audio unit
|
||||
let resetStatus = AudioUnitReset(audioUnit, kAudioUnitScope_Global, 0)
|
||||
if resetStatus != noErr {
|
||||
logger.error("Failed to reset audio unit: \(resetStatus)")
|
||||
}
|
||||
|
||||
logger.info("Configuring audio unit for device ID: \(deviceID)")
|
||||
let setDeviceResult = AudioUnitSetProperty(
|
||||
audioUnit,
|
||||
kAudioOutputUnitProperty_CurrentDevice,
|
||||
kAudioUnitScope_Global,
|
||||
0,
|
||||
&deviceIDCopy,
|
||||
propertySize
|
||||
)
|
||||
|
||||
if setDeviceResult != noErr {
|
||||
logger.error("Failed to set audio unit device: \(setDeviceResult)")
|
||||
logger.error("Device ID: \(deviceID)")
|
||||
if let deviceName = AudioDeviceManager.shared.getDeviceName(deviceID: deviceID) {
|
||||
logger.error("Failed device name: \(deviceName)")
|
||||
}
|
||||
throw AudioConfigurationError.failedToSetAudioUnitDevice(status: setDeviceResult)
|
||||
}
|
||||
|
||||
logger.info("Successfully configured audio unit")
|
||||
// Add a small delay to allow the device to settle
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
|
||||
/// Sets the default input device for recording
|
||||
/// - Parameter deviceID: The ID of the audio device
|
||||
static func setDefaultInputDevice(_ deviceID: AudioDeviceID) throws {
|
||||
var deviceIDCopy = deviceID
|
||||
let propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
let setDeviceResult = AudioObjectSetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
propertySize,
|
||||
&deviceIDCopy
|
||||
)
|
||||
|
||||
if setDeviceResult != noErr {
|
||||
logger.error("Failed to set input device: \(setDeviceResult)")
|
||||
throw AudioConfigurationError.failedToSetInputDevice(status: setDeviceResult)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a device change observer
|
||||
/// - Parameters:
|
||||
/// - handler: The closure to execute when device changes
|
||||
/// - queue: The queue to execute the handler on (defaults to main queue)
|
||||
/// - Returns: The observer token
|
||||
static func createDeviceChangeObserver(
|
||||
handler: @escaping () -> Void,
|
||||
queue: OperationQueue = .main
|
||||
) -> NSObjectProtocol {
|
||||
return NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name("AudioDeviceChanged"),
|
||||
object: nil,
|
||||
queue: queue,
|
||||
using: { _ in handler() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum AudioConfigurationError: LocalizedError {
|
||||
case failedToGetDeviceFormat(status: OSStatus)
|
||||
case failedToSetAudioUnitDevice(status: OSStatus)
|
||||
case failedToSetInputDevice(status: OSStatus)
|
||||
case failedToGetAudioUnit
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .failedToGetDeviceFormat(let status):
|
||||
return "Failed to get device format: \(status)"
|
||||
case .failedToSetAudioUnitDevice(let status):
|
||||
return "Failed to set audio unit device: \(status)"
|
||||
case .failedToSetInputDevice(let status):
|
||||
return "Failed to set input device: \(status)"
|
||||
case .failedToGetAudioUnit:
|
||||
return "Failed to get audio unit from input node"
|
||||
}
|
||||
}
|
||||
}
|
||||
457
VoiceInk/Services/AudioDeviceManager.swift
Normal file
@ -0,0 +1,457 @@
|
||||
import Foundation
|
||||
import CoreAudio
|
||||
import AVFoundation
|
||||
import os
|
||||
|
||||
struct PrioritizedDevice: Codable, Identifiable {
|
||||
let id: String // Device UID
|
||||
let name: String
|
||||
let priority: Int
|
||||
}
|
||||
|
||||
enum AudioInputMode: String, CaseIterable {
|
||||
case systemDefault = "System Default"
|
||||
case custom = "Custom Device"
|
||||
case prioritized = "Prioritized"
|
||||
}
|
||||
|
||||
class AudioDeviceManager: ObservableObject {
|
||||
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "AudioDeviceManager")
|
||||
@Published var availableDevices: [(id: AudioDeviceID, uid: String, name: String)] = []
|
||||
@Published var selectedDeviceID: AudioDeviceID?
|
||||
@Published var inputMode: AudioInputMode = .systemDefault
|
||||
@Published var prioritizedDevices: [PrioritizedDevice] = []
|
||||
private var fallbackDeviceID: AudioDeviceID?
|
||||
|
||||
static let shared = AudioDeviceManager()
|
||||
|
||||
init() {
|
||||
setupFallbackDevice()
|
||||
loadPrioritizedDevices()
|
||||
loadAvailableDevices { [weak self] in
|
||||
self?.initializeSelectedDevice()
|
||||
}
|
||||
|
||||
// Load saved input mode
|
||||
if let savedMode = UserDefaults.standard.string(forKey: "audioInputMode"),
|
||||
let mode = AudioInputMode(rawValue: savedMode) {
|
||||
inputMode = mode
|
||||
}
|
||||
|
||||
// Setup device change notifications
|
||||
setupDeviceChangeNotifications()
|
||||
}
|
||||
|
||||
private func setupFallbackDevice() {
|
||||
let deviceID: AudioDeviceID? = getDeviceProperty(
|
||||
deviceID: AudioObjectID(kAudioObjectSystemObject),
|
||||
selector: kAudioHardwarePropertyDefaultInputDevice
|
||||
)
|
||||
|
||||
if let deviceID = deviceID {
|
||||
fallbackDeviceID = deviceID
|
||||
if let name = getDeviceName(deviceID: deviceID) {
|
||||
logger.info("Fallback device set to: \(name) (ID: \(deviceID))")
|
||||
}
|
||||
} else {
|
||||
logger.error("Failed to get fallback device")
|
||||
}
|
||||
}
|
||||
|
||||
private func initializeSelectedDevice() {
|
||||
if inputMode == .prioritized {
|
||||
selectHighestPriorityAvailableDevice()
|
||||
return
|
||||
}
|
||||
|
||||
// Try to load saved device
|
||||
if let savedID = UserDefaults.standard.object(forKey: "selectedAudioDeviceID") as? AudioDeviceID {
|
||||
// Verify the saved device still exists and is valid
|
||||
if isDeviceAvailable(savedID) {
|
||||
selectedDeviceID = savedID
|
||||
logger.info("Loaded saved device ID: \(savedID)")
|
||||
if let name = getDeviceName(deviceID: savedID) {
|
||||
logger.info("Using saved device: \(name)")
|
||||
}
|
||||
} else {
|
||||
logger.warning("Saved device ID \(savedID) is no longer available")
|
||||
fallbackToDefaultDevice()
|
||||
}
|
||||
} else {
|
||||
fallbackToDefaultDevice()
|
||||
}
|
||||
}
|
||||
|
||||
private func isDeviceAvailable(_ deviceID: AudioDeviceID) -> Bool {
|
||||
return availableDevices.contains { $0.id == deviceID }
|
||||
}
|
||||
|
||||
private func fallbackToDefaultDevice() {
|
||||
if let fallbackID = fallbackDeviceID {
|
||||
selectedDeviceID = fallbackID
|
||||
logger.info("Using fallback device ID: \(fallbackID)")
|
||||
if let name = getDeviceName(deviceID: fallbackID) {
|
||||
logger.info("Fallback to built-in microphone: \(name)")
|
||||
}
|
||||
} else {
|
||||
logger.error("No fallback device available")
|
||||
}
|
||||
}
|
||||
|
||||
func loadAvailableDevices(completion: (() -> Void)? = nil) {
|
||||
logger.info("Loading available audio devices...")
|
||||
var propertySize: UInt32 = 0
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
var result = AudioObjectGetPropertyDataSize(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&propertySize
|
||||
)
|
||||
|
||||
let deviceCount = Int(propertySize) / MemoryLayout<AudioDeviceID>.size
|
||||
logger.info("Found \(deviceCount) total audio devices")
|
||||
|
||||
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
|
||||
|
||||
result = AudioObjectGetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&propertySize,
|
||||
&deviceIDs
|
||||
)
|
||||
|
||||
if result != noErr {
|
||||
logger.error("Error getting audio devices: \(result)")
|
||||
return
|
||||
}
|
||||
|
||||
let devices = deviceIDs.compactMap { deviceID -> (id: AudioDeviceID, uid: String, name: String)? in
|
||||
guard let name = getDeviceName(deviceID: deviceID),
|
||||
let uid = getDeviceUID(deviceID: deviceID),
|
||||
isInputDevice(deviceID: deviceID) else {
|
||||
return nil
|
||||
}
|
||||
return (id: deviceID, uid: uid, name: name)
|
||||
}
|
||||
|
||||
logger.info("Found \(devices.count) input devices")
|
||||
devices.forEach { device in
|
||||
logger.info("Available device: \(device.name) (ID: \(device.id))")
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.availableDevices = devices.map { ($0.id, $0.uid, $0.name) }
|
||||
// Verify current selection is still valid
|
||||
if let currentID = self.selectedDeviceID, !devices.contains(where: { $0.id == currentID }) {
|
||||
self.logger.warning("Currently selected device is no longer available")
|
||||
self.fallbackToDefaultDevice()
|
||||
}
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
func getDeviceName(deviceID: AudioDeviceID) -> String? {
|
||||
let name: CFString? = getDeviceProperty(deviceID: deviceID,
|
||||
selector: kAudioDevicePropertyDeviceNameCFString)
|
||||
return name as String?
|
||||
}
|
||||
|
||||
private func isInputDevice(deviceID: AudioDeviceID) -> Bool {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||
mScope: kAudioDevicePropertyScopeInput,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
var propertySize: UInt32 = 0
|
||||
var result = AudioObjectGetPropertyDataSize(
|
||||
deviceID,
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&propertySize
|
||||
)
|
||||
|
||||
if result != noErr {
|
||||
logger.error("Error checking input capability for device \(deviceID): \(result)")
|
||||
return false
|
||||
}
|
||||
|
||||
let bufferList = UnsafeMutablePointer<AudioBufferList>.allocate(capacity: Int(propertySize))
|
||||
defer { bufferList.deallocate() }
|
||||
|
||||
result = AudioObjectGetPropertyData(
|
||||
deviceID,
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&propertySize,
|
||||
bufferList
|
||||
)
|
||||
|
||||
if result != noErr {
|
||||
logger.error("Error getting stream configuration for device \(deviceID): \(result)")
|
||||
return false
|
||||
}
|
||||
|
||||
let bufferCount = Int(bufferList.pointee.mNumberBuffers)
|
||||
return bufferCount > 0
|
||||
}
|
||||
|
||||
func selectDevice(id: AudioDeviceID) {
|
||||
logger.info("Selecting device with ID: \(id)")
|
||||
if let name = getDeviceName(deviceID: id) {
|
||||
logger.info("Selected device name: \(name)")
|
||||
}
|
||||
|
||||
if isDeviceAvailable(id) {
|
||||
DispatchQueue.main.async {
|
||||
self.selectedDeviceID = id
|
||||
UserDefaults.standard.set(id, forKey: "selectedAudioDeviceID")
|
||||
self.logger.info("Device selection saved")
|
||||
self.notifyDeviceChange()
|
||||
}
|
||||
} else {
|
||||
logger.error("Attempted to select unavailable device: \(id)")
|
||||
fallbackToDefaultDevice()
|
||||
}
|
||||
}
|
||||
|
||||
func selectInputMode(_ mode: AudioInputMode) {
|
||||
inputMode = mode
|
||||
UserDefaults.standard.set(mode.rawValue, forKey: "audioInputMode")
|
||||
|
||||
if mode == .systemDefault {
|
||||
selectedDeviceID = nil
|
||||
UserDefaults.standard.removeObject(forKey: "selectedAudioDeviceID")
|
||||
} else if selectedDeviceID == nil {
|
||||
if let firstDevice = availableDevices.first {
|
||||
selectDevice(id: firstDevice.id)
|
||||
}
|
||||
}
|
||||
|
||||
notifyDeviceChange()
|
||||
}
|
||||
|
||||
func getCurrentDevice() -> AudioDeviceID {
|
||||
switch inputMode {
|
||||
case .systemDefault:
|
||||
return fallbackDeviceID ?? 0
|
||||
case .custom:
|
||||
return selectedDeviceID ?? fallbackDeviceID ?? 0
|
||||
case .prioritized:
|
||||
let sortedDevices = prioritizedDevices.sorted { $0.priority < $1.priority }
|
||||
for device in sortedDevices {
|
||||
if let available = availableDevices.first(where: { $0.uid == device.id }) {
|
||||
return available.id
|
||||
}
|
||||
}
|
||||
return fallbackDeviceID ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPrioritizedDevices() {
|
||||
if let data = UserDefaults.standard.data(forKey: "prioritizedDevices"),
|
||||
let devices = try? JSONDecoder().decode([PrioritizedDevice].self, from: data) {
|
||||
prioritizedDevices = devices
|
||||
logger.info("Loaded \(devices.count) prioritized devices")
|
||||
}
|
||||
}
|
||||
|
||||
func savePrioritizedDevices() {
|
||||
if let data = try? JSONEncoder().encode(prioritizedDevices) {
|
||||
UserDefaults.standard.set(data, forKey: "prioritizedDevices")
|
||||
logger.info("Saved \(self.prioritizedDevices.count) prioritized devices")
|
||||
}
|
||||
}
|
||||
|
||||
func addPrioritizedDevice(uid: String, name: String) {
|
||||
guard !prioritizedDevices.contains(where: { $0.id == uid }) else { return }
|
||||
let nextPriority = (prioritizedDevices.map { $0.priority }.max() ?? -1) + 1
|
||||
let device = PrioritizedDevice(id: uid, name: name, priority: nextPriority)
|
||||
prioritizedDevices.append(device)
|
||||
savePrioritizedDevices()
|
||||
}
|
||||
|
||||
func removePrioritizedDevice(id: String) {
|
||||
let wasSelected = selectedDeviceID == availableDevices.first(where: { $0.uid == id })?.id
|
||||
prioritizedDevices.removeAll { $0.id == id }
|
||||
|
||||
// Reindex remaining devices to ensure continuous priority numbers
|
||||
let updatedDevices = prioritizedDevices.enumerated().map { index, device in
|
||||
PrioritizedDevice(id: device.id, name: device.name, priority: index)
|
||||
}
|
||||
|
||||
prioritizedDevices = updatedDevices
|
||||
savePrioritizedDevices()
|
||||
|
||||
// If we removed the currently selected device, select the next best option
|
||||
if wasSelected && inputMode == .prioritized {
|
||||
selectHighestPriorityAvailableDevice()
|
||||
}
|
||||
}
|
||||
|
||||
func updatePriorities(devices: [PrioritizedDevice]) {
|
||||
prioritizedDevices = devices
|
||||
savePrioritizedDevices()
|
||||
|
||||
if inputMode == .prioritized {
|
||||
selectHighestPriorityAvailableDevice()
|
||||
}
|
||||
|
||||
notifyDeviceChange()
|
||||
}
|
||||
|
||||
private func selectHighestPriorityAvailableDevice() {
|
||||
// Sort by priority (lowest number = highest priority)
|
||||
let sortedDevices = prioritizedDevices.sorted { $0.priority < $1.priority }
|
||||
|
||||
// Try each device in priority order
|
||||
for device in sortedDevices {
|
||||
if let availableDevice = availableDevices.first(where: { $0.uid == device.id }) {
|
||||
selectedDeviceID = availableDevice.id
|
||||
logger.info("Selected prioritized device: \(device.name) (Priority: \(device.priority))")
|
||||
|
||||
// Actually set the device as the current input device
|
||||
do {
|
||||
try AudioDeviceConfiguration.setDefaultInputDevice(availableDevice.id)
|
||||
UserDefaults.standard.set(availableDevice.id, forKey: "selectedAudioDeviceID")
|
||||
} catch {
|
||||
logger.error("Failed to set prioritized device: \(error.localizedDescription)")
|
||||
continue // Try next device if this one fails
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If no prioritized device is available, fall back to default
|
||||
fallbackToDefaultDevice()
|
||||
}
|
||||
|
||||
private func setupDeviceChangeNotifications() {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
let systemObjectID = AudioObjectID(kAudioObjectSystemObject)
|
||||
|
||||
// Add listener for device changes
|
||||
let status = AudioObjectAddPropertyListener(
|
||||
systemObjectID,
|
||||
&address,
|
||||
{ (_, _, _, userData) -> OSStatus in
|
||||
let manager = Unmanaged<AudioDeviceManager>.fromOpaque(userData!).takeUnretainedValue()
|
||||
DispatchQueue.main.async {
|
||||
manager.handleDeviceListChange()
|
||||
}
|
||||
return noErr
|
||||
},
|
||||
UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
|
||||
)
|
||||
|
||||
if status != noErr {
|
||||
logger.error("Failed to add device change listener: \(status)")
|
||||
} else {
|
||||
logger.info("Successfully added device change listener")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDeviceListChange() {
|
||||
logger.info("Device list change detected")
|
||||
loadAvailableDevices { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// If in prioritized mode, recheck the device selection
|
||||
if self.inputMode == .prioritized {
|
||||
self.selectHighestPriorityAvailableDevice()
|
||||
}
|
||||
// If in custom mode and selected device is no longer available, fallback
|
||||
else if self.inputMode == .custom,
|
||||
let currentID = self.selectedDeviceID,
|
||||
!self.isDeviceAvailable(currentID) {
|
||||
self.fallbackToDefaultDevice()
|
||||
}
|
||||
|
||||
// Notify UI of changes
|
||||
NotificationCenter.default.post(name: NSNotification.Name("AudioDeviceChanged"), object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func getDeviceUID(deviceID: AudioDeviceID) -> String? {
|
||||
let uid: CFString? = getDeviceProperty(deviceID: deviceID,
|
||||
selector: kAudioDevicePropertyDeviceUID)
|
||||
return uid as String?
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Remove the listener when the manager is deallocated
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
AudioObjectRemovePropertyListener(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
{ (_, _, _, userData) -> OSStatus in
|
||||
return noErr
|
||||
},
|
||||
UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
private func createPropertyAddress(selector: AudioObjectPropertySelector,
|
||||
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal,
|
||||
element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain) -> AudioObjectPropertyAddress {
|
||||
return AudioObjectPropertyAddress(
|
||||
mSelector: selector,
|
||||
mScope: scope,
|
||||
mElement: element
|
||||
)
|
||||
}
|
||||
|
||||
private func getDeviceProperty<T>(deviceID: AudioDeviceID,
|
||||
selector: AudioObjectPropertySelector,
|
||||
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal) -> T? {
|
||||
// Skip invalid device IDs
|
||||
guard deviceID != 0 else { return nil }
|
||||
|
||||
var address = createPropertyAddress(selector: selector, scope: scope)
|
||||
var propertySize = UInt32(MemoryLayout<T>.size)
|
||||
var property: T? = nil
|
||||
|
||||
let status = AudioObjectGetPropertyData(
|
||||
deviceID,
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&propertySize,
|
||||
&property
|
||||
)
|
||||
|
||||
if status != noErr {
|
||||
logger.error("Failed to get device property \(selector) for device \(deviceID): \(status)")
|
||||
return nil
|
||||
}
|
||||
|
||||
return property
|
||||
}
|
||||
|
||||
private func notifyDeviceChange() {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("AudioDeviceChanged"), object: nil)
|
||||
}
|
||||
}
|
||||
122
VoiceInk/Services/BrowserURLService.swift
Normal file
@ -0,0 +1,122 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
enum BrowserType {
|
||||
case safari
|
||||
case arc
|
||||
case chrome
|
||||
case edge
|
||||
case firefox
|
||||
case brave
|
||||
case opera
|
||||
case vivaldi
|
||||
case orion
|
||||
case zen
|
||||
|
||||
var scriptName: String {
|
||||
switch self {
|
||||
case .safari: return "safariURL"
|
||||
case .arc: return "arcURL"
|
||||
case .chrome: return "chromeURL"
|
||||
case .edge: return "edgeURL"
|
||||
case .firefox: return "firefoxURL"
|
||||
case .brave: return "braveURL"
|
||||
case .opera: return "operaURL"
|
||||
case .vivaldi: return "vivaldiURL"
|
||||
case .orion: return "orionURL"
|
||||
case .zen: return "zenURL"
|
||||
}
|
||||
}
|
||||
|
||||
var bundleIdentifier: String {
|
||||
switch self {
|
||||
case .safari: return "com.apple.Safari"
|
||||
case .arc: return "company.thebrowser.Browser"
|
||||
case .chrome: return "com.google.Chrome"
|
||||
case .edge: return "com.microsoft.edgemac"
|
||||
case .firefox: return "org.mozilla.firefox"
|
||||
case .brave: return "com.brave.Browser"
|
||||
case .opera: return "com.operasoftware.Opera"
|
||||
case .vivaldi: return "com.vivaldi.Vivaldi"
|
||||
case .orion: return "com.kagi.kagimacOS"
|
||||
case .zen: return "app.zen-browser.zen"
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .safari: return "Safari"
|
||||
case .arc: return "Arc"
|
||||
case .chrome: return "Google Chrome"
|
||||
case .edge: return "Microsoft Edge"
|
||||
case .firefox: return "Firefox"
|
||||
case .brave: return "Brave"
|
||||
case .opera: return "Opera"
|
||||
case .vivaldi: return "Vivaldi"
|
||||
case .orion: return "Orion"
|
||||
case .zen: return "Zen Browser"
|
||||
}
|
||||
}
|
||||
|
||||
static var allCases: [BrowserType] {
|
||||
[.safari, .arc, .chrome, .edge, .firefox, .brave, .opera, .vivaldi, .orion, .zen]
|
||||
}
|
||||
|
||||
static var installedBrowsers: [BrowserType] {
|
||||
allCases.filter { browser in
|
||||
let workspace = NSWorkspace.shared
|
||||
return workspace.urlForApplication(withBundleIdentifier: browser.bundleIdentifier) != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BrowserURLError: Error {
|
||||
case scriptNotFound
|
||||
case executionFailed
|
||||
case browserNotRunning
|
||||
case noActiveWindow
|
||||
case noActiveTab
|
||||
}
|
||||
|
||||
class BrowserURLService {
|
||||
static let shared = BrowserURLService()
|
||||
|
||||
private init() {}
|
||||
|
||||
func getCurrentURL(from browser: BrowserType) async throws -> String {
|
||||
guard let scriptURL = Bundle.main.url(forResource: browser.scriptName, withExtension: "scpt") else {
|
||||
throw BrowserURLError.scriptNotFound
|
||||
}
|
||||
|
||||
let task = Process()
|
||||
task.launchPath = "/usr/bin/osascript"
|
||||
task.arguments = [scriptURL.path]
|
||||
|
||||
let pipe = Pipe()
|
||||
task.standardOutput = pipe
|
||||
task.standardError = pipe
|
||||
|
||||
do {
|
||||
try task.run()
|
||||
task.waitUntilExit()
|
||||
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
if let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) {
|
||||
if output.isEmpty {
|
||||
throw BrowserURLError.noActiveTab
|
||||
}
|
||||
return output
|
||||
} else {
|
||||
throw BrowserURLError.executionFailed
|
||||
}
|
||||
} catch {
|
||||
throw BrowserURLError.executionFailed
|
||||
}
|
||||
}
|
||||
|
||||
func isRunning(_ browser: BrowserType) -> Bool {
|
||||
let workspace = NSWorkspace.shared
|
||||
let runningApps = workspace.runningApplications
|
||||
return runningApps.contains { $0.bundleIdentifier == browser.bundleIdentifier }
|
||||
}
|
||||
}
|
||||
187
VoiceInk/Services/OllamaService.swift
Normal file
@ -0,0 +1,187 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class OllamaService: ObservableObject {
|
||||
static let defaultBaseURL = "http://localhost:11434"
|
||||
|
||||
// MARK: - Response Types
|
||||
struct OllamaModel: Codable, Identifiable {
|
||||
let name: String
|
||||
let modified_at: String
|
||||
let size: Int64
|
||||
let digest: String
|
||||
let details: ModelDetails
|
||||
|
||||
var id: String { name }
|
||||
|
||||
struct ModelDetails: Codable {
|
||||
let format: String
|
||||
let family: String
|
||||
let families: [String]
|
||||
let parameter_size: String
|
||||
let quantization_level: String
|
||||
}
|
||||
}
|
||||
|
||||
struct OllamaModelsResponse: Codable {
|
||||
let models: [OllamaModel]
|
||||
}
|
||||
|
||||
struct OllamaResponse: Codable {
|
||||
let response: String
|
||||
}
|
||||
|
||||
// MARK: - Published Properties
|
||||
@Published var baseURL: String {
|
||||
didSet {
|
||||
UserDefaults.standard.set(baseURL, forKey: "ollamaBaseURL")
|
||||
}
|
||||
}
|
||||
|
||||
@Published var selectedModel: String {
|
||||
didSet {
|
||||
UserDefaults.standard.set(selectedModel, forKey: "ollamaSelectedModel")
|
||||
}
|
||||
}
|
||||
|
||||
@Published var availableModels: [OllamaModel] = []
|
||||
@Published var isConnected: Bool = false
|
||||
@Published var isLoadingModels: Bool = false
|
||||
|
||||
private let defaultTemperature: Double = 0.3
|
||||
|
||||
init() {
|
||||
self.baseURL = UserDefaults.standard.string(forKey: "ollamaBaseURL") ?? Self.defaultBaseURL
|
||||
self.selectedModel = UserDefaults.standard.string(forKey: "ollamaSelectedModel") ?? "llama2"
|
||||
|
||||
// Initial connection check and model list fetch
|
||||
Task {
|
||||
await checkConnection()
|
||||
if isConnected {
|
||||
await refreshModels()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func checkConnection() async {
|
||||
guard let url = URL(string: baseURL) else {
|
||||
isConnected = false
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let (_, response) = try await URLSession.shared.data(from: url)
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
isConnected = (200...299).contains(httpResponse.statusCode)
|
||||
} else {
|
||||
isConnected = false
|
||||
}
|
||||
} catch {
|
||||
isConnected = false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func refreshModels() async {
|
||||
isLoadingModels = true
|
||||
defer { isLoadingModels = false }
|
||||
|
||||
do {
|
||||
let models = try await fetchAvailableModels()
|
||||
availableModels = models
|
||||
|
||||
// If selected model is not in available models, select first available
|
||||
if !models.contains(where: { $0.name == selectedModel }) && !models.isEmpty {
|
||||
selectedModel = models[0].name
|
||||
}
|
||||
} catch {
|
||||
print("Error fetching models: \(error)")
|
||||
availableModels = []
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchAvailableModels() async throws -> [OllamaModel] {
|
||||
guard let url = URL(string: "\(baseURL)/api/tags") else {
|
||||
throw LocalAIError.invalidURL
|
||||
}
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let response = try JSONDecoder().decode(OllamaModelsResponse.self, from: data)
|
||||
return response.models
|
||||
}
|
||||
|
||||
func enhance(_ text: String, withSystemPrompt systemPrompt: String? = nil) async throws -> String {
|
||||
guard let url = URL(string: "\(baseURL)/api/generate") else {
|
||||
throw LocalAIError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
guard let systemPrompt = systemPrompt else {
|
||||
throw LocalAIError.invalidRequest
|
||||
}
|
||||
|
||||
print("\nOllama Enhancement Debug:")
|
||||
print("Original Text: \(text)")
|
||||
print("System Prompt: \(systemPrompt)")
|
||||
|
||||
let body: [String: Any] = [
|
||||
"model": selectedModel,
|
||||
"prompt": text,
|
||||
"system": systemPrompt,
|
||||
"temperature": defaultTemperature,
|
||||
"stream": false
|
||||
]
|
||||
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw LocalAIError.invalidResponse
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
let response = try JSONDecoder().decode(OllamaResponse.self, from: data)
|
||||
print("Enhanced Text: \(response.response)\n")
|
||||
return response.response
|
||||
case 404:
|
||||
throw LocalAIError.modelNotFound
|
||||
case 500:
|
||||
throw LocalAIError.serverError
|
||||
default:
|
||||
throw LocalAIError.invalidResponse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Types
|
||||
enum LocalAIError: Error, LocalizedError {
|
||||
case invalidURL
|
||||
case serviceUnavailable
|
||||
case invalidResponse
|
||||
case modelNotFound
|
||||
case serverError
|
||||
case invalidRequest
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "Invalid Ollama server URL"
|
||||
case .serviceUnavailable:
|
||||
return "Ollama service is not available"
|
||||
case .invalidResponse:
|
||||
return "Invalid response from Ollama server"
|
||||
case .modelNotFound:
|
||||
return "Selected model not found"
|
||||
case .serverError:
|
||||
return "Ollama server error"
|
||||
case .invalidRequest:
|
||||
return "System prompt is required"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
VoiceInk/Services/PolarService.swift
Normal file
@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
class PolarService {
|
||||
private let organizationId = "6f3d781d-a630-4435-9dba-058486f2d936"
|
||||
private let apiToken = "polar_pat_U7rxicH_Jn9szpse_kzgmDHRr_gH6UD8AzAFGRGZdbM"
|
||||
private let baseURL = "https://api.polar.sh"
|
||||
|
||||
struct LicenseValidationResponse: Codable {
|
||||
let status: String
|
||||
}
|
||||
|
||||
func validateLicenseKey(_ key: String) async throws -> Bool {
|
||||
let url = URL(string: "\(baseURL)/v1/users/license-keys/validate")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let body: [String: String] = [
|
||||
"key": key,
|
||||
"organization_id": organizationId
|
||||
]
|
||||
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, httpResponse) = try await URLSession.shared.data(for: request)
|
||||
|
||||
if let httpResponse = httpResponse as? HTTPURLResponse,
|
||||
!(200...299).contains(httpResponse.statusCode) {
|
||||
print("HTTP Status Code: \(httpResponse.statusCode)")
|
||||
if let errorString = String(data: data, encoding: .utf8) {
|
||||
print("Error Response: \(errorString)")
|
||||
}
|
||||
}
|
||||
|
||||
let validationResponse = try JSONDecoder().decode(LicenseValidationResponse.self, from: data)
|
||||
return validationResponse.status == "granted"
|
||||
}
|
||||
}
|
||||
115
VoiceInk/Services/ScreenCaptureService.swift
Normal file
@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import Vision
|
||||
|
||||
class ScreenCaptureService: ObservableObject {
|
||||
@Published var isCapturing = false
|
||||
@Published var lastCapturedText: String?
|
||||
|
||||
private func getActiveWindowInfo() -> (title: String, ownerName: String, windowID: CGWindowID)? {
|
||||
let options = CGWindowListOption([.optionOnScreenOnly, .excludeDesktopElements])
|
||||
let windowListInfo = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
|
||||
|
||||
// Find the frontmost window that isn't our own app
|
||||
guard let frontWindow = windowListInfo.first(where: { info in
|
||||
let layer = info[kCGWindowLayer as String] as? Int32 ?? 0
|
||||
let ownerName = info[kCGWindowOwnerName as String] as? String ?? ""
|
||||
// Exclude our own app and system UI elements
|
||||
return layer == 0 && ownerName != "VoiceInk" && !ownerName.contains("Dock") && !ownerName.contains("Menu Bar")
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let windowID = frontWindow[kCGWindowNumber as String] as? CGWindowID,
|
||||
let ownerName = frontWindow[kCGWindowOwnerName as String] as? String,
|
||||
let title = frontWindow[kCGWindowName as String] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (title: title, ownerName: ownerName, windowID: windowID)
|
||||
}
|
||||
|
||||
func captureActiveWindow() -> NSImage? {
|
||||
guard let windowInfo = getActiveWindowInfo() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Capture the window
|
||||
let cgImage = CGWindowListCreateImage(
|
||||
.null,
|
||||
.optionIncludingWindow,
|
||||
windowInfo.windowID,
|
||||
[.boundsIgnoreFraming, .bestResolution]
|
||||
)
|
||||
|
||||
guard let cgImage = cgImage else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
|
||||
}
|
||||
|
||||
func extractText(from image: NSImage, completion: @escaping (String?) -> Void) {
|
||||
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let requestHandler = VNImageRequestHandler(cgImage: cgImage, options: [:])
|
||||
let request = VNRecognizeTextRequest { request, error in
|
||||
guard error == nil,
|
||||
let observations = request.results as? [VNRecognizedTextObservation] else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let text = observations.compactMap { observation in
|
||||
observation.topCandidates(1).first?.string
|
||||
}.joined(separator: "\n")
|
||||
|
||||
completion(text)
|
||||
}
|
||||
|
||||
// Configure the recognition level
|
||||
request.recognitionLevel = .accurate
|
||||
|
||||
do {
|
||||
try requestHandler.perform([request])
|
||||
} catch {
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func captureAndExtractText() async -> String? {
|
||||
guard !isCapturing else { return nil }
|
||||
|
||||
isCapturing = true
|
||||
defer { isCapturing = false }
|
||||
|
||||
// First get window info
|
||||
guard let windowInfo = getActiveWindowInfo() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start with window metadata
|
||||
var contextText = """
|
||||
Active Window: \(windowInfo.title)
|
||||
Application: \(windowInfo.ownerName)
|
||||
|
||||
"""
|
||||
|
||||
// Then capture and process window content
|
||||
if let capturedImage = captureActiveWindow() {
|
||||
if let extractedText = await withCheckedContinuation({ continuation in
|
||||
extractText(from: capturedImage) { text in
|
||||
continuation.resume(returning: text)
|
||||
}
|
||||
}) {
|
||||
contextText += "Window Content:\n\(extractedText)"
|
||||
}
|
||||
}
|
||||
|
||||
self.lastCapturedText = contextText
|
||||
return contextText
|
||||
}
|
||||
}
|
||||
27
VoiceInk/Services/UserDefaultsManager.swift
Normal file
@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
extension UserDefaults {
|
||||
enum Keys {
|
||||
static let aiProviderApiKey = "VoiceInkAIProviderKey"
|
||||
static let licenseKey = "VoiceInkLicense"
|
||||
static let trialStartDate = "VoiceInkTrialStartDate"
|
||||
}
|
||||
|
||||
// MARK: - AI Provider API Key
|
||||
var aiProviderApiKey: String? {
|
||||
get { string(forKey: Keys.aiProviderApiKey) }
|
||||
set { setValue(newValue, forKey: Keys.aiProviderApiKey) }
|
||||
}
|
||||
|
||||
// MARK: - License Key
|
||||
var licenseKey: String? {
|
||||
get { string(forKey: Keys.licenseKey) }
|
||||
set { setValue(newValue, forKey: Keys.licenseKey) }
|
||||
}
|
||||
|
||||
// MARK: - Trial Start Date
|
||||
var trialStartDate: Date? {
|
||||
get { object(forKey: Keys.trialStartDate) as? Date }
|
||||
set { setValue(newValue, forKey: Keys.trialStartDate) }
|
||||
}
|
||||
}
|
||||
77
VoiceInk/SoundManager.swift
Normal file
@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
class SoundManager {
|
||||
static let shared = SoundManager()
|
||||
|
||||
private var startSound: AVAudioPlayer?
|
||||
private var stopSound: AVAudioPlayer?
|
||||
|
||||
@AppStorage("isSoundFeedbackEnabled") private var isSoundFeedbackEnabled = false
|
||||
|
||||
private init() {
|
||||
setupSounds()
|
||||
}
|
||||
|
||||
private func setupSounds() {
|
||||
print("Attempting to load sound files...")
|
||||
|
||||
// Try loading directly from the main bundle
|
||||
if let startSoundURL = Bundle.main.url(forResource: "start", withExtension: "mp3"),
|
||||
let stopSoundURL = Bundle.main.url(forResource: "Stop", withExtension: "mp3") {
|
||||
print("Found sounds in main bundle")
|
||||
try? loadSounds(start: startSoundURL, stop: stopSoundURL)
|
||||
return
|
||||
}
|
||||
|
||||
print("⚠️ Could not find sound files in the main bundle")
|
||||
print("Bundle path: \(Bundle.main.bundlePath)")
|
||||
|
||||
// List contents of the bundle for debugging
|
||||
if let bundleURL = Bundle.main.resourceURL {
|
||||
do {
|
||||
let contents = try FileManager.default.contentsOfDirectory(at: bundleURL, includingPropertiesForKeys: nil)
|
||||
print("Contents of bundle resource directory:")
|
||||
contents.forEach { print($0.lastPathComponent) }
|
||||
} catch {
|
||||
print("Error listing bundle contents: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSounds(start startURL: URL, stop stopURL: URL) throws {
|
||||
do {
|
||||
startSound = try AVAudioPlayer(contentsOf: startURL)
|
||||
stopSound = try AVAudioPlayer(contentsOf: stopURL)
|
||||
|
||||
// Set lower volume for both sounds
|
||||
startSound?.volume = 0.2
|
||||
stopSound?.volume = 0.2
|
||||
|
||||
// Prepare sounds for instant playback
|
||||
startSound?.prepareToPlay()
|
||||
stopSound?.prepareToPlay()
|
||||
|
||||
print("✅ Successfully loaded both sound files")
|
||||
} catch {
|
||||
print("❌ Error loading sounds: \(error.localizedDescription)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func playStartSound() {
|
||||
guard isSoundFeedbackEnabled else { return }
|
||||
startSound?.play()
|
||||
}
|
||||
|
||||
func playStopSound() {
|
||||
guard isSoundFeedbackEnabled else { return }
|
||||
stopSound?.play()
|
||||
}
|
||||
|
||||
var isEnabled: Bool {
|
||||
get { isSoundFeedbackEnabled }
|
||||
set { isSoundFeedbackEnabled = newValue }
|
||||
}
|
||||
}
|
||||
122
VoiceInk/ViewModels/LicenseViewModel.swift
Normal file
@ -0,0 +1,122 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
class LicenseViewModel: ObservableObject {
|
||||
enum LicenseState: Equatable {
|
||||
case trial(daysRemaining: Int)
|
||||
case trialExpired
|
||||
case licensed
|
||||
}
|
||||
|
||||
@Published private(set) var licenseState: LicenseState = .trial(daysRemaining: 7) // Default to trial
|
||||
@Published var licenseKey: String = ""
|
||||
@Published var isValidating = false
|
||||
@Published var validationMessage: String?
|
||||
|
||||
private let trialPeriodDays = 7
|
||||
private let polarService = PolarService()
|
||||
private let userDefaults = UserDefaults.standard
|
||||
|
||||
init() {
|
||||
checkLicenseState()
|
||||
}
|
||||
|
||||
func startTrial() {
|
||||
// Only set trial start date if it hasn't been set before
|
||||
if userDefaults.trialStartDate == nil {
|
||||
userDefaults.trialStartDate = Date()
|
||||
licenseState = .trial(daysRemaining: trialPeriodDays)
|
||||
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func checkLicenseState() {
|
||||
// Check for existing license key
|
||||
if let licenseKey = userDefaults.licenseKey {
|
||||
self.licenseKey = licenseKey
|
||||
licenseState = .licensed
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is first launch
|
||||
let hasLaunchedBefore = userDefaults.bool(forKey: "VoiceInkHasLaunchedBefore")
|
||||
if !hasLaunchedBefore {
|
||||
// First launch - start trial automatically
|
||||
userDefaults.set(true, forKey: "VoiceInkHasLaunchedBefore")
|
||||
startTrial()
|
||||
return
|
||||
}
|
||||
|
||||
// Only check trial if not licensed and not first launch
|
||||
if let trialStartDate = userDefaults.trialStartDate {
|
||||
let daysSinceTrialStart = Calendar.current.dateComponents([.day], from: trialStartDate, to: Date()).day ?? 0
|
||||
|
||||
if daysSinceTrialStart >= trialPeriodDays {
|
||||
licenseState = .trialExpired
|
||||
} else {
|
||||
licenseState = .trial(daysRemaining: trialPeriodDays - daysSinceTrialStart)
|
||||
}
|
||||
} else {
|
||||
// No trial has been started yet - start it now
|
||||
startTrial()
|
||||
}
|
||||
}
|
||||
|
||||
var canUseApp: Bool {
|
||||
switch licenseState {
|
||||
case .licensed, .trial:
|
||||
return true
|
||||
case .trialExpired:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func openPurchaseLink() {
|
||||
if let url = URL(string: "https://tryvoiceink.com/buy") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
func validateLicense() async {
|
||||
guard !licenseKey.isEmpty else {
|
||||
validationMessage = "Please enter a license key"
|
||||
return
|
||||
}
|
||||
|
||||
isValidating = true
|
||||
|
||||
do {
|
||||
let isValid = try await polarService.validateLicenseKey(licenseKey)
|
||||
if isValid {
|
||||
userDefaults.licenseKey = licenseKey
|
||||
licenseState = .licensed
|
||||
validationMessage = "License activated successfully!"
|
||||
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
|
||||
} else {
|
||||
validationMessage = "Invalid license key"
|
||||
}
|
||||
} catch {
|
||||
validationMessage = "Error validating license"
|
||||
}
|
||||
|
||||
isValidating = false
|
||||
}
|
||||
|
||||
func removeLicense() {
|
||||
// Remove both license key and trial data
|
||||
userDefaults.licenseKey = nil
|
||||
userDefaults.trialStartDate = nil
|
||||
userDefaults.set(false, forKey: "VoiceInkHasLaunchedBefore") // Allow trial to restart
|
||||
|
||||
licenseState = .trial(daysRemaining: trialPeriodDays) // Reset to trial state
|
||||
licenseKey = ""
|
||||
validationMessage = nil
|
||||
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
|
||||
checkLicenseState()
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let licenseStatusChanged = Notification.Name("licenseStatusChanged")
|
||||
}
|
||||
581
VoiceInk/Views/APIKeyManagementView.swift
Normal file
@ -0,0 +1,581 @@
|
||||
import SwiftUI
|
||||
|
||||
struct APIKeyManagementView: View {
|
||||
@EnvironmentObject private var aiService: AIService
|
||||
@State private var apiKey: String = ""
|
||||
@State private var showAlert = false
|
||||
@State private var alertMessage = ""
|
||||
@State private var isVerifying = false
|
||||
@State private var ollamaBaseURL: String = UserDefaults.standard.string(forKey: "ollamaBaseURL") ?? "http://localhost:11434"
|
||||
@State private var ollamaModels: [OllamaService.OllamaModel] = []
|
||||
@State private var selectedOllamaModel: String = UserDefaults.standard.string(forKey: "ollamaSelectedModel") ?? "mistral"
|
||||
@State private var isCheckingOllama = false
|
||||
@State private var isEditingURL = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header Section
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Enhance your transcriptions with AI")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if aiService.isAPIKeyValid && aiService.selectedProvider != .ollama {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.frame(width: 8, height: 8)
|
||||
Text("Connected to")
|
||||
.font(.caption)
|
||||
Text(aiService.selectedProvider.rawValue)
|
||||
.font(.caption.bold())
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.foregroundColor(.secondary)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
|
||||
// Provider Selection
|
||||
Picker("AI Provider", selection: $aiService.selectedProvider) {
|
||||
ForEach(AIProvider.allCases, id: \.self) { provider in
|
||||
Text(provider.rawValue).tag(provider)
|
||||
}
|
||||
}
|
||||
|
||||
.onChange(of: aiService.selectedProvider) { _ in
|
||||
if aiService.selectedProvider == .ollama {
|
||||
checkOllamaConnection()
|
||||
}
|
||||
}
|
||||
|
||||
if aiService.selectedProvider == .ollama {
|
||||
// Ollama Configuration
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
HStack {
|
||||
Label("Ollama Configuration", systemImage: "server.rack")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Connection Status Indicator
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(isCheckingOllama ? Color.orange : (ollamaModels.isEmpty ? Color.red : Color.green))
|
||||
.frame(width: 8, height: 8)
|
||||
Text(isCheckingOllama ? "Checking..." : (ollamaModels.isEmpty ? "Disconnected" : "Connected"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
|
||||
// Base URL Configuration
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Server URL", systemImage: "link")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if isEditingURL {
|
||||
TextField("Base URL", text: $ollamaBaseURL)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
|
||||
Button(action: {
|
||||
aiService.updateOllamaBaseURL(ollamaBaseURL)
|
||||
checkOllamaConnection()
|
||||
isEditingURL = false
|
||||
}) {
|
||||
Text("Save")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Text(ollamaBaseURL)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
isEditingURL = true
|
||||
}) {
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.controlSize(.small)
|
||||
|
||||
Button(action: {
|
||||
ollamaBaseURL = "http://localhost:11434"
|
||||
aiService.updateOllamaBaseURL(ollamaBaseURL)
|
||||
checkOllamaConnection()
|
||||
}) {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundColor(.secondary)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
|
||||
// Model Selection
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Model Selection", systemImage: "cpu")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if ollamaModels.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text("No models available")
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.orange.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(ollamaModels) { model in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Model Name and Status
|
||||
HStack {
|
||||
Text(model.name)
|
||||
.font(.subheadline)
|
||||
.bold()
|
||||
|
||||
if model.name == selectedOllamaModel {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
|
||||
// Model Details
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Parameters
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "cpu.fill")
|
||||
.font(.caption2)
|
||||
Text(model.details.parameter_size)
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
// Size
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "externaldrive.fill")
|
||||
.font(.caption2)
|
||||
Text(formatSize(model.size))
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(minWidth: 140)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(model.name == selectedOllamaModel ? Color.accentColor.opacity(0.1) : Color.secondary.opacity(0.05))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(model.name == selectedOllamaModel ? Color.accentColor : Color.clear, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.onTapGesture {
|
||||
selectedOllamaModel = model.name
|
||||
aiService.updateSelectedOllamaModel(model.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4) // Add padding for the first and last items
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh Button
|
||||
Button(action: {
|
||||
checkOllamaConnection()
|
||||
}) {
|
||||
Label(isCheckingOllama ? "Refreshing..." : "Refresh Models", systemImage: isCheckingOllama ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
|
||||
.font(.caption)
|
||||
}
|
||||
.disabled(isCheckingOllama)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
|
||||
// Help Text
|
||||
if ollamaModels.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Troubleshooting")
|
||||
.font(.subheadline)
|
||||
.bold()
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
bulletPoint("Ensure Ollama is installed and running")
|
||||
bulletPoint("Check if the server URL is correct")
|
||||
bulletPoint("Verify you have at least one model pulled")
|
||||
}
|
||||
|
||||
Button("Learn More") {
|
||||
NSWorkspace.shared.open(URL(string: "https://ollama.ai/download")!)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
// Ollama Information
|
||||
DisclosureGroup {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Important Warning about Model Size
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.frame(width: 20)
|
||||
.foregroundColor(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Important: Model Selection")
|
||||
.font(.subheadline)
|
||||
.bold()
|
||||
.foregroundColor(.orange)
|
||||
Text("Smaller models (< 7B parameters) significantly impact transcription enhancement quality. For optimal results, use models with 14B+ parameters. Also reasoning models don't work with transcript enhancement. So avoid them.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.orange.opacity(0.1))
|
||||
.cornerRadius(6)
|
||||
|
||||
// Local Processing
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: "cpu")
|
||||
.frame(width: 20)
|
||||
.foregroundColor(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Local Processing")
|
||||
.font(.subheadline)
|
||||
.bold()
|
||||
Text("Ollama runs entirely on your system, processing all text locally without sending data to external servers.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// System Requirements
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: "memorychip")
|
||||
.frame(width: 20)
|
||||
.foregroundColor(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("System Requirements")
|
||||
.font(.subheadline)
|
||||
.bold()
|
||||
Text("Local processing requires significant system resources. Larger, more capable models need more RAM (32GB+ recommended for optimal performance).")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Use Cases
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: "checkmark.shield")
|
||||
.frame(width: 20)
|
||||
.foregroundColor(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Best For")
|
||||
.font(.subheadline)
|
||||
.bold()
|
||||
Text("• Privacy-focused users who need data to stay local\n• Systems with powerful hardware\n• Users who can prioritize quality over processing speed")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Recommendation Note
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: "lightbulb")
|
||||
.frame(width: 20)
|
||||
.foregroundColor(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Recommendation")
|
||||
.font(.subheadline)
|
||||
.bold()
|
||||
Text("For optimal transcription enhancement, either use cloud providers or ensure you're using a larger local model (14B+ parameters). Smaller models may produce poor or inconsistent results.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Important Information About Local AI", systemImage: "info.circle.fill")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.secondary.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.secondary.opacity(0.03))
|
||||
.cornerRadius(12)
|
||||
} else if aiService.selectedProvider == .custom {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Custom Provider Configuration")
|
||||
.font(.headline)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
.font(.caption)
|
||||
Text("Requires OpenAI-compatible API endpoint")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration Fields
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextField("Base URL (e.g., https://api.example.com/v1/chat/completions)", text: $aiService.customBaseURL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
TextField("Model Name (e.g., gpt-4o-mini, claude-3-5-sonnet-20240620)", text: $aiService.customModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
if aiService.isAPIKeyValid {
|
||||
// Show masked API key when valid
|
||||
Text("API Key")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack {
|
||||
Text(String(repeating: "•", count: 40))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
aiService.clearAPIKey()
|
||||
}) {
|
||||
Label("Remove Key", systemImage: "trash")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
} else {
|
||||
// Show API key input when not valid
|
||||
Text("Enter your API Key")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
SecureField("API Key", text: $apiKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
HStack {
|
||||
Button(action: {
|
||||
isVerifying = true
|
||||
aiService.saveAPIKey(apiKey) { success in
|
||||
isVerifying = false
|
||||
if !success {
|
||||
alertMessage = "Invalid API key. Please check and try again."
|
||||
showAlert = true
|
||||
}
|
||||
apiKey = ""
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
if isVerifying {
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
.frame(width: 16, height: 16)
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
}
|
||||
Text("Verify and Save")
|
||||
}
|
||||
}
|
||||
.disabled(aiService.customBaseURL.isEmpty || aiService.customModel.isEmpty || apiKey.isEmpty)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.secondary.opacity(0.03))
|
||||
.cornerRadius(12)
|
||||
} else if aiService.isAPIKeyValid {
|
||||
// API Key Display for other providers
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("API Key")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack {
|
||||
Text(String(repeating: "•", count: 40))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
aiService.clearAPIKey()
|
||||
}) {
|
||||
Label("Remove Key", systemImage: "trash")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// API Key Input for other providers
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Enter your API Key")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
SecureField("API Key", text: $apiKey)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
HStack {
|
||||
Button(action: {
|
||||
isVerifying = true
|
||||
aiService.saveAPIKey(apiKey) { success in
|
||||
isVerifying = false
|
||||
if !success {
|
||||
alertMessage = "Invalid API key. Please check and try again."
|
||||
showAlert = true
|
||||
}
|
||||
apiKey = ""
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
if isVerifying {
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
.frame(width: 16, height: 16)
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
}
|
||||
Text("Verify and Save")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(aiService.selectedProvider == .groq || aiService.selectedProvider == .gemini ? "Free" : "Paid")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
|
||||
if aiService.selectedProvider != .ollama && aiService.selectedProvider != .custom {
|
||||
Button {
|
||||
let url = switch aiService.selectedProvider {
|
||||
case .groq:
|
||||
URL(string: "https://console.groq.com/keys")!
|
||||
case .openAI:
|
||||
URL(string: "https://platform.openai.com/api-keys")!
|
||||
case .deepSeek:
|
||||
URL(string: "https://platform.deepseek.com/api-keys")!
|
||||
case .gemini:
|
||||
URL(string: "https://makersuite.google.com/app/apikey")!
|
||||
case .anthropic:
|
||||
URL(string: "https://console.anthropic.com/settings/keys")!
|
||||
case .ollama, .custom:
|
||||
URL(string: "")! // This case should never be reached
|
||||
|
||||
}
|
||||
NSWorkspace.shared.open(url)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("Get API Key")
|
||||
.foregroundColor(.accentColor)
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.alert("Error", isPresented: $showAlert) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text(alertMessage)
|
||||
}
|
||||
.onAppear {
|
||||
if aiService.selectedProvider == .ollama {
|
||||
checkOllamaConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkOllamaConnection() {
|
||||
isCheckingOllama = true
|
||||
aiService.checkOllamaConnection { connected in
|
||||
if connected {
|
||||
Task {
|
||||
ollamaModels = await aiService.fetchOllamaModels()
|
||||
isCheckingOllama = false
|
||||
}
|
||||
} else {
|
||||
ollamaModels = []
|
||||
isCheckingOllama = false
|
||||
alertMessage = "Could not connect to Ollama. Please check if Ollama is running and the base URL is correct."
|
||||
showAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bulletPoint(_ text: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
Text("•")
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatSize(_ bytes: Int64) -> String {
|
||||
let gigabytes = Double(bytes) / 1_000_000_000
|
||||
return String(format: "%.1f GB", gigabytes)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
APIKeyManagementView()
|
||||
.environmentObject(AIService())
|
||||
}
|
||||
|
||||
|
||||
|
||||
147
VoiceInk/Views/AboutView.swift
Normal file
@ -0,0 +1,147 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct AboutView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
VStack {
|
||||
Spacer()
|
||||
CardView {
|
||||
VStack(spacing: 30) {
|
||||
appLogo
|
||||
appDescription
|
||||
featuresSection
|
||||
contactInfo
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(width: min(geometry.size.width * 0.9, 600))
|
||||
.frame(minHeight: min(geometry.size.height * 0.9, 800))
|
||||
Spacer()
|
||||
}
|
||||
.frame(minWidth: geometry.size.width, minHeight: geometry.size.height)
|
||||
}
|
||||
.padding() // Add padding here
|
||||
}
|
||||
}
|
||||
|
||||
private var appLogo: some View {
|
||||
Group {
|
||||
if let image = NSImage(named: "AppIcon") {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 128, height: 128)
|
||||
.cornerRadius(16)
|
||||
.shadow(radius: 5)
|
||||
} else {
|
||||
Image(systemName: "questionmark.app.dashed")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 128, height: 128)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("VoiceInk App Icon")
|
||||
}
|
||||
|
||||
private var appDescription: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("VoiceInk")
|
||||
.font(.system(size: 36, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Version \(appVersion)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("VoiceInk is a powerful voice-to-text application that leverages local whisper AI models to provide accurate and efficient transcription in real-time.")
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
.frame(maxWidth: 600)
|
||||
}
|
||||
}
|
||||
|
||||
private var featuresSection: some View {
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
Text("Key Features")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 5)
|
||||
|
||||
FeatureRow(icon: "waveform", text: "Real-time transcription")
|
||||
FeatureRow(icon: "globe", text: "Support for multiple languages")
|
||||
FeatureRow(icon: "keyboard", text: "Global hotkey for quick access")
|
||||
FeatureRow(icon: "chart.bar", text: "VoiceInk insights and metrics")
|
||||
FeatureRow(icon: "lock.shield", text: "Privacy-focused with local processing")
|
||||
}
|
||||
.padding()
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color(.controlBackgroundColor)))
|
||||
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
|
||||
}
|
||||
|
||||
private var contactInfo: some View {
|
||||
VStack(spacing: 15) {
|
||||
Text("Contact Us")
|
||||
.font(.headline)
|
||||
|
||||
Button(action: {
|
||||
if let url = URL(string: "mailto:prakashjoshipax@gmail.com?subject=VoiceInk%20Help%20%26%20Support") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Text("prakashjoshipax@gmail.com")
|
||||
.underline()
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
Text("© 2025 VoiceInk. All rights reserved.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color(.controlBackgroundColor)))
|
||||
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
struct FeatureRow: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 15) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: 24, height: 24)
|
||||
Text(text)
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CardView<Content: View>: View {
|
||||
let content: Content
|
||||
|
||||
init(@ViewBuilder content: () -> Content) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.background(Color(.controlBackgroundColor))
|
||||
.cornerRadius(20)
|
||||
.shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 5)
|
||||
}
|
||||
}
|
||||
|
||||
struct AboutView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AboutView()
|
||||
}
|
||||
}
|
||||
321
VoiceInk/Views/AudioPlayerView.swift
Normal file
@ -0,0 +1,321 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
class WaveformGenerator {
|
||||
static func generateWaveformSamples(from url: URL, sampleCount: Int = 200) -> [Float] {
|
||||
guard let audioFile = try? AVAudioFile(forReading: url) else { return [] }
|
||||
let format = audioFile.processingFormat
|
||||
|
||||
// Calculate frame count and read size
|
||||
let frameCount = UInt32(audioFile.length)
|
||||
let samplesPerFrame = frameCount / UInt32(sampleCount)
|
||||
var samples = [Float](repeating: 0.0, count: sampleCount)
|
||||
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return [] }
|
||||
|
||||
do {
|
||||
try audioFile.read(into: buffer)
|
||||
|
||||
// Get the raw audio data
|
||||
guard let channelData = buffer.floatChannelData?[0] else { return [] }
|
||||
|
||||
// Process the samples
|
||||
for i in 0..<sampleCount {
|
||||
let startFrame = UInt32(i) * samplesPerFrame
|
||||
let endFrame = min(startFrame + samplesPerFrame, frameCount)
|
||||
var maxAmplitude: Float = 0.0
|
||||
|
||||
// Find the highest amplitude in this segment
|
||||
for frame in startFrame..<endFrame {
|
||||
let amplitude = abs(channelData[Int(frame)])
|
||||
maxAmplitude = max(maxAmplitude, amplitude)
|
||||
}
|
||||
|
||||
samples[i] = maxAmplitude
|
||||
}
|
||||
|
||||
// Normalize the samples
|
||||
if let maxSample = samples.max(), maxSample > 0 {
|
||||
samples = samples.map { $0 / maxSample }
|
||||
}
|
||||
|
||||
return samples
|
||||
} catch {
|
||||
print("Error reading audio file: \(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AudioPlayerManager: ObservableObject {
|
||||
private var audioPlayer: AVAudioPlayer?
|
||||
@Published var isPlaying = false
|
||||
@Published var currentTime: TimeInterval = 0
|
||||
@Published var duration: TimeInterval = 0
|
||||
@Published var waveformSamples: [Float] = []
|
||||
private var timer: Timer?
|
||||
|
||||
func loadAudio(from url: URL) {
|
||||
do {
|
||||
audioPlayer = try AVAudioPlayer(contentsOf: url)
|
||||
audioPlayer?.prepareToPlay()
|
||||
duration = audioPlayer?.duration ?? 0
|
||||
// Generate waveform data
|
||||
waveformSamples = WaveformGenerator.generateWaveformSamples(from: url)
|
||||
} catch {
|
||||
print("Error loading audio: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func play() {
|
||||
audioPlayer?.play()
|
||||
isPlaying = true
|
||||
startTimer()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
audioPlayer?.pause()
|
||||
isPlaying = false
|
||||
stopTimer()
|
||||
}
|
||||
|
||||
func seek(to time: TimeInterval) {
|
||||
audioPlayer?.currentTime = time
|
||||
currentTime = time
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.currentTime = self.audioPlayer?.currentTime ?? 0
|
||||
if self.currentTime >= self.duration {
|
||||
self.pause()
|
||||
self.seek(to: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopTimer()
|
||||
}
|
||||
}
|
||||
|
||||
struct WaveformView: View {
|
||||
let samples: [Float]
|
||||
let currentTime: TimeInterval
|
||||
let duration: TimeInterval
|
||||
var onSeek: (Double) -> Void
|
||||
@State private var isHovering = false
|
||||
@State private var hoverLocation: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
// Removed the glass-morphic background and its overlays
|
||||
|
||||
// Waveform container
|
||||
HStack(spacing: 1) {
|
||||
ForEach(0..<samples.count, id: \.self) { index in
|
||||
WaveformBar(
|
||||
sample: samples[index],
|
||||
isPlayed: CGFloat(index) / CGFloat(samples.count) <= CGFloat(currentTime / duration),
|
||||
totalBars: samples.count,
|
||||
geometryWidth: geometry.size.width,
|
||||
isHovering: isHovering,
|
||||
hoverProgress: hoverLocation / geometry.size.width
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.horizontal, 2)
|
||||
|
||||
// Hover time indicator
|
||||
if isHovering {
|
||||
// Time bubble
|
||||
Text(formatTime(duration * Double(hoverLocation / geometry.size.width)))
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.accentColor)
|
||||
.shadow(color: Color.black.opacity(0.1), radius: 3, x: 0, y: 2)
|
||||
)
|
||||
.offset(x: max(0, min(hoverLocation - 30, geometry.size.width - 60)))
|
||||
.offset(y: -30)
|
||||
|
||||
// Progress line
|
||||
Rectangle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 2)
|
||||
.frame(maxHeight: .infinity)
|
||||
.offset(x: hoverLocation)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
hoverLocation = value.location.x
|
||||
let progress = max(0, min(value.location.x / geometry.size.width, 1))
|
||||
onSeek(Double(progress) * duration)
|
||||
}
|
||||
)
|
||||
.onHover { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
.onContinuousHover { phase in
|
||||
switch phase {
|
||||
case .active(let location):
|
||||
hoverLocation = location.x
|
||||
case .ended:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 56)
|
||||
}
|
||||
|
||||
private func formatTime(_ time: TimeInterval) -> String {
|
||||
let minutes = Int(time) / 60
|
||||
let seconds = Int(time) % 60
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
struct WaveformBar: View {
|
||||
let sample: Float
|
||||
let isPlayed: Bool
|
||||
let totalBars: Int
|
||||
let geometryWidth: CGFloat
|
||||
let isHovering: Bool
|
||||
let hoverProgress: CGFloat
|
||||
|
||||
private var barProgress: CGFloat {
|
||||
CGFloat(sample)
|
||||
}
|
||||
|
||||
private var isNearHover: Bool {
|
||||
let barPosition = CGFloat(geometryWidth) / CGFloat(totalBars)
|
||||
let hoverPosition = hoverProgress * geometryWidth
|
||||
return abs(barPosition - hoverPosition) < 20
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Capsule()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
isPlayed ? Color.accentColor : Color.accentColor.opacity(0.3),
|
||||
isPlayed ? Color.accentColor.opacity(0.8) : Color.accentColor.opacity(0.2)
|
||||
]),
|
||||
startPoint: .bottom,
|
||||
endPoint: .top
|
||||
)
|
||||
)
|
||||
.frame(
|
||||
width: max((geometryWidth / CGFloat(totalBars)) - 1, 1),
|
||||
height: max(barProgress * 40, 3)
|
||||
)
|
||||
.scaleEffect(y: isHovering && isNearHover ? 1.2 : 1.0)
|
||||
.animation(.interpolatingSpring(stiffness: 300, damping: 15), value: isHovering && isNearHover)
|
||||
}
|
||||
}
|
||||
|
||||
struct AudioPlayerView: View {
|
||||
let url: URL
|
||||
@StateObject private var playerManager = AudioPlayerManager()
|
||||
@State private var isHovering = false
|
||||
@State private var showingTooltip = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
// Title and duration
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "waveform")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Text("Recording")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(formatTime(playerManager.duration))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// Waveform and controls container
|
||||
VStack(spacing: 16) {
|
||||
// Waveform
|
||||
WaveformView(
|
||||
samples: playerManager.waveformSamples,
|
||||
currentTime: playerManager.currentTime,
|
||||
duration: playerManager.duration,
|
||||
onSeek: { time in
|
||||
playerManager.seek(to: time)
|
||||
}
|
||||
)
|
||||
|
||||
// Controls
|
||||
HStack(spacing: 20) {
|
||||
// Play/Pause button
|
||||
Button(action: {
|
||||
if playerManager.isPlaying {
|
||||
playerManager.pause()
|
||||
} else {
|
||||
playerManager.play()
|
||||
}
|
||||
}) {
|
||||
Circle()
|
||||
.fill(Color.accentColor.opacity(0.1))
|
||||
.frame(width: 44, height: 44)
|
||||
.overlay(
|
||||
Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.contentTransition(.symbolEffect(.replace.downUp))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.scaleEffect(isHovering ? 1.05 : 1.0)
|
||||
.onHover { hovering in
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
|
||||
// Time
|
||||
Text(formatTime(playerManager.currentTime))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 16)
|
||||
.onAppear {
|
||||
playerManager.loadAudio(from: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ time: TimeInterval) -> String {
|
||||
let minutes = Int(time) / 60
|
||||
let seconds = Int(time) % 60
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
19
VoiceInk/Views/Components/ProBadge.swift
Normal file
@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ProBadge: View {
|
||||
var body: some View {
|
||||
Text("PRO")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.blue.opacity(0.8))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ProBadge()
|
||||
}
|
||||
77
VoiceInk/Views/Components/TrialMessageView.swift
Normal file
@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TrialMessageView: View {
|
||||
let message: String
|
||||
let type: MessageType
|
||||
|
||||
enum MessageType {
|
||||
case warning
|
||||
case expired
|
||||
case info
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(iconColor)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if type == .expired || type == .warning {
|
||||
Button(action: {
|
||||
if let url = URL(string: "https://tryvoiceink.com/buy") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Text(type == .expired ? "Upgrade Now" : "Upgrade")
|
||||
.font(.headline)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(backgroundColor)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
switch type {
|
||||
case .warning: return "exclamationmark.triangle.fill"
|
||||
case .expired: return "xmark.circle.fill"
|
||||
case .info: return "info.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
switch type {
|
||||
case .warning: return .orange
|
||||
case .expired: return .red
|
||||
case .info: return .blue
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch type {
|
||||
case .warning: return "Trial Ending Soon"
|
||||
case .expired: return "Trial Expired"
|
||||
case .info: return "Trial Active"
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
switch type {
|
||||
case .warning: return Color.orange.opacity(0.1)
|
||||
case .expired: return Color.red.opacity(0.1)
|
||||
case .info: return Color.blue.opacity(0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
43
VoiceInk/Views/Components/VideoCTAView.swift
Normal file
@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VideoCTAView: View {
|
||||
let url: String
|
||||
let subtitle: String
|
||||
|
||||
var body: some View {
|
||||
Link(destination: URL(string: url)!) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(Color.blue)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Watch how it works")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.windowBackgroundColor).opacity(0.9))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.secondary.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
266
VoiceInk/Views/ContentView.swift
Normal file
@ -0,0 +1,266 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import KeyboardShortcuts
|
||||
|
||||
// ViewType enum with all cases
|
||||
enum ViewType: String, CaseIterable {
|
||||
case metrics = "Dashboard"
|
||||
case record = "Record Audio"
|
||||
case history = "History"
|
||||
case models = "AI Models"
|
||||
case enhancement = "Enhancement"
|
||||
case powerMode = "Power Mode"
|
||||
case permissions = "Permissions"
|
||||
case audioInput = "Audio Input"
|
||||
case dictionary = "Dictionary"
|
||||
case license = "VoiceInk Pro"
|
||||
case settings = "Settings"
|
||||
case about = "About"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .metrics: return "gauge.medium"
|
||||
case .record: return "mic.circle.fill"
|
||||
case .history: return "doc.text.fill"
|
||||
case .models: return "brain.head.profile"
|
||||
case .enhancement: return "wand.and.stars"
|
||||
case .powerMode: return "sparkles.square.fill.on.square"
|
||||
case .permissions: return "shield.fill"
|
||||
case .audioInput: return "mic.fill"
|
||||
case .dictionary: return "character.book.closed.fill"
|
||||
case .license: return "checkmark.seal.fill"
|
||||
case .settings: return "gearshape.fill"
|
||||
case .about: return "info.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VisualEffectView: NSViewRepresentable {
|
||||
let material: NSVisualEffectView.Material
|
||||
let blendingMode: NSVisualEffectView.BlendingMode
|
||||
|
||||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||||
let visualEffectView = NSVisualEffectView()
|
||||
visualEffectView.material = material
|
||||
visualEffectView.blendingMode = blendingMode
|
||||
visualEffectView.state = .active
|
||||
return visualEffectView
|
||||
}
|
||||
|
||||
func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) {
|
||||
visualEffectView.material = material
|
||||
visualEffectView.blendingMode = blendingMode
|
||||
}
|
||||
}
|
||||
|
||||
struct DynamicSidebar: View {
|
||||
@Binding var selectedView: ViewType
|
||||
@Binding var hoveredView: ViewType?
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@StateObject private var licenseViewModel = LicenseViewModel()
|
||||
@Namespace private var buttonAnimation
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 15) {
|
||||
// App Header
|
||||
HStack(spacing: 6) {
|
||||
if let appIcon = NSImage(named: "AppIcon") {
|
||||
Image(nsImage: appIcon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 28, height: 28)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
Text("VoiceInk")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
|
||||
if case .licensed = licenseViewModel.licenseState {
|
||||
Text("PRO")
|
||||
.font(.system(size: 9, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
// Navigation Items
|
||||
ForEach(ViewType.allCases, id: \.self) { viewType in
|
||||
DynamicSidebarButton(
|
||||
title: viewType.rawValue,
|
||||
systemImage: viewType.icon,
|
||||
isSelected: selectedView == viewType,
|
||||
isHovered: hoveredView == viewType,
|
||||
namespace: buttonAnimation
|
||||
) {
|
||||
selectedView = viewType
|
||||
}
|
||||
.onHover { isHovered in
|
||||
hoveredView = isHovered ? viewType : nil
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct DynamicSidebarButton: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let isSelected: Bool
|
||||
let isHovered: Bool
|
||||
let namespace: Namespace.ID
|
||||
let action: () -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
.foregroundColor(isSelected ? .white : (isHovered ? .accentColor : .primary))
|
||||
.frame(height: 40)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 16)
|
||||
.background(
|
||||
ZStack {
|
||||
if isSelected {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.accentColor)
|
||||
.shadow(color: Color.accentColor.opacity(0.5), radius: 5, x: 0, y: 2)
|
||||
} else if isHovered {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(colorScheme == .dark ? Color.white.opacity(0.1) : Color.black.opacity(0.05))
|
||||
}
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@EnvironmentObject private var whisperState: WhisperState
|
||||
@EnvironmentObject private var hotkeyManager: HotkeyManager
|
||||
@State private var selectedView: ViewType = .metrics
|
||||
@State private var hoveredView: ViewType?
|
||||
@State private var hasLoadedData = false
|
||||
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
||||
@StateObject private var licenseViewModel = LicenseViewModel()
|
||||
|
||||
private var isSetupComplete: Bool {
|
||||
hasLoadedData &&
|
||||
whisperState.currentModel != nil &&
|
||||
KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil &&
|
||||
AXIsProcessTrusted() &&
|
||||
CGPreflightScreenCaptureAccess()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
DynamicSidebar(
|
||||
selectedView: $selectedView,
|
||||
hoveredView: $hoveredView
|
||||
)
|
||||
.frame(width: 200)
|
||||
.navigationSplitViewColumnWidth(200)
|
||||
.background(VisualEffectView(material: .sidebar, blendingMode: .behindWindow))
|
||||
} detail: {
|
||||
detailView
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.toolbar(.hidden, for: .automatic)
|
||||
.navigationTitle("")
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.frame(minWidth: 1100, minHeight: 750)
|
||||
.background(Color(.controlBackgroundColor))
|
||||
.onAppear {
|
||||
hasLoadedData = true
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToDestination)) { notification in
|
||||
print("ContentView: Received navigation notification")
|
||||
if let destination = notification.userInfo?["destination"] as? String {
|
||||
print("ContentView: Destination received: \(destination)")
|
||||
switch destination {
|
||||
case "Settings":
|
||||
print("ContentView: Navigating to Settings")
|
||||
selectedView = .settings
|
||||
case "AI Models":
|
||||
print("ContentView: Navigating to AI Models")
|
||||
selectedView = .models
|
||||
case "VoiceInk Pro":
|
||||
print("ContentView: Navigating to VoiceInk Pro")
|
||||
selectedView = .license
|
||||
case "History":
|
||||
print("ContentView: Navigating to History")
|
||||
selectedView = .history
|
||||
case "Permissions":
|
||||
print("ContentView: Navigating to Permissions")
|
||||
selectedView = .permissions
|
||||
case "Enhancement":
|
||||
print("ContentView: Navigating to Enhancement")
|
||||
selectedView = .enhancement
|
||||
default:
|
||||
print("ContentView: No matching destination found for: \(destination)")
|
||||
break
|
||||
}
|
||||
} else {
|
||||
print("ContentView: No destination in notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detailView: some View {
|
||||
switch selectedView {
|
||||
case .metrics:
|
||||
if isSetupComplete {
|
||||
MetricsView(skipSetupCheck: true)
|
||||
} else {
|
||||
MetricsSetupView()
|
||||
}
|
||||
case .models:
|
||||
ModelManagementView(whisperState: whisperState)
|
||||
case .enhancement:
|
||||
EnhancementSettingsView()
|
||||
case .record:
|
||||
RecordView()
|
||||
case .history:
|
||||
TranscriptionHistoryView()
|
||||
case .audioInput:
|
||||
AudioInputSettingsView()
|
||||
case .dictionary:
|
||||
DictionarySettingsView()
|
||||
case .powerMode:
|
||||
PowerModeView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
.environmentObject(whisperState)
|
||||
case .about:
|
||||
AboutView()
|
||||
case .license:
|
||||
LicenseManagementView()
|
||||
case .permissions:
|
||||
PermissionsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
145
VoiceInk/Views/Dictionary/DictionarySettingsView.swift
Normal file
@ -0,0 +1,145 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DictionarySettingsView: View {
|
||||
@State private var selectedSection: DictionarySection = .spellings
|
||||
|
||||
enum DictionarySection: String, CaseIterable {
|
||||
case spellings = "Correct Spellings"
|
||||
case replacements = "Word Replacements"
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .spellings:
|
||||
return "Train VoiceInk to recognize industry terms, names, and technical words"
|
||||
case .replacements:
|
||||
return "Automatically replace specific words/phrases with custom formatted text "
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .spellings:
|
||||
return "character.book.closed.fill"
|
||||
case .replacements:
|
||||
return "arrow.2.squarepath"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
heroSection
|
||||
mainContent
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
}
|
||||
|
||||
private var heroSection: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "brain.filled.head.profile")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.blue)
|
||||
.padding(20)
|
||||
.background(Circle()
|
||||
.fill(Color(.windowBackgroundColor).opacity(0.9))
|
||||
.shadow(color: .black.opacity(0.1), radius: 10, y: 5))
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Dictionary Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
Text("Enhance VoiceInk's transcription accuracy by teaching it your vocabulary")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 400)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 40)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
VStack(spacing: 40) {
|
||||
sectionSelector
|
||||
|
||||
selectedSectionContent
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
private var sectionSelector: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Select Section")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
ForEach(DictionarySection.allCases, id: \.self) { section in
|
||||
SectionCard(
|
||||
section: section,
|
||||
isSelected: selectedSection == section,
|
||||
action: { selectedSection = section }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedSectionContent: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
switch selectedSection {
|
||||
case .spellings:
|
||||
DictionaryView()
|
||||
.background(Color(.windowBackgroundColor).opacity(0.4))
|
||||
.cornerRadius(10)
|
||||
case .replacements:
|
||||
WordReplacementView()
|
||||
.background(Color(.windowBackgroundColor).opacity(0.4))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SectionCard: View {
|
||||
let section: DictionarySettingsView.DictionarySection
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Image(systemName: section.icon)
|
||||
.font(.system(size: 28))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(isSelected ? .blue : .secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(section.rawValue)
|
||||
.font(.headline)
|
||||
|
||||
Text(section.description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(.windowBackgroundColor).opacity(0.4))
|
||||
.shadow(color: isSelected ? .blue.opacity(0.2) : .clear, radius: 8, y: 4)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(isSelected ? .blue.opacity(0.5) : .clear, lineWidth: 2)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
256
VoiceInk/Views/Dictionary/DictionaryView.swift
Normal file
@ -0,0 +1,256 @@
|
||||
import SwiftUI
|
||||
|
||||
// Old format for migration
|
||||
private struct LegacyDictionaryItem: Codable {
|
||||
let id: UUID
|
||||
var word: String
|
||||
var dateAdded: Date
|
||||
}
|
||||
|
||||
struct DictionaryItem: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
var word: String
|
||||
var dateAdded: Date
|
||||
var isEnabled: Bool
|
||||
|
||||
// Migration initializer
|
||||
fileprivate init(from legacy: LegacyDictionaryItem) {
|
||||
self.id = legacy.id
|
||||
self.word = legacy.word
|
||||
self.dateAdded = legacy.dateAdded
|
||||
self.isEnabled = true // Default to enabled for migrated items
|
||||
}
|
||||
|
||||
// Standard initializer
|
||||
init(word: String, dateAdded: Date = Date(), isEnabled: Bool = true) {
|
||||
self.id = UUID()
|
||||
self.word = word
|
||||
self.dateAdded = dateAdded
|
||||
self.isEnabled = isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
class DictionaryManager: ObservableObject {
|
||||
@Published var items: [DictionaryItem] = []
|
||||
private let saveKey = "CustomDictionaryItems"
|
||||
var onDictionaryChanged: (([String]) -> Void)?
|
||||
|
||||
init() {
|
||||
loadItems()
|
||||
}
|
||||
|
||||
private func loadItems() {
|
||||
guard let data = UserDefaults.standard.data(forKey: saveKey) else { return }
|
||||
|
||||
// Try loading with new format first
|
||||
if let savedItems = try? JSONDecoder().decode([DictionaryItem].self, from: data) {
|
||||
items = savedItems.sorted(by: { $0.dateAdded > $1.dateAdded })
|
||||
} else {
|
||||
// If that fails, try loading old format and migrate
|
||||
if let legacyItems = try? JSONDecoder().decode([LegacyDictionaryItem].self, from: data) {
|
||||
items = legacyItems.map(DictionaryItem.init).sorted(by: { $0.dateAdded > $1.dateAdded })
|
||||
// Save in new format immediately
|
||||
saveItems()
|
||||
}
|
||||
}
|
||||
notifyDictionaryChanged()
|
||||
}
|
||||
|
||||
private func saveItems() {
|
||||
if let encoded = try? JSONEncoder().encode(items) {
|
||||
UserDefaults.standard.set(encoded, forKey: saveKey)
|
||||
notifyDictionaryChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyDictionaryChanged() {
|
||||
// Only include enabled words in the dictionary
|
||||
let enabledWords = items.filter { $0.isEnabled }.map { $0.word }
|
||||
onDictionaryChanged?(enabledWords)
|
||||
}
|
||||
|
||||
func addWord(_ word: String) {
|
||||
let normalizedWord = word.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !items.contains(where: { $0.word.lowercased() == normalizedWord.lowercased() }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let newItem = DictionaryItem(word: normalizedWord)
|
||||
items.insert(newItem, at: 0)
|
||||
saveItems()
|
||||
}
|
||||
|
||||
func removeWord(_ word: String) {
|
||||
items.removeAll(where: { $0.word == word })
|
||||
saveItems()
|
||||
}
|
||||
|
||||
func toggleWordState(id: UUID) {
|
||||
if let index = items.firstIndex(where: { $0.id == id }) {
|
||||
items[index].isEnabled.toggle()
|
||||
saveItems()
|
||||
}
|
||||
}
|
||||
|
||||
var allWords: [String] {
|
||||
items.filter { $0.isEnabled }.map { $0.word }
|
||||
}
|
||||
}
|
||||
|
||||
struct DictionaryView: View {
|
||||
@StateObject private var dictionaryManager = DictionaryManager()
|
||||
@EnvironmentObject private var whisperState: WhisperState
|
||||
@State private var newWord = ""
|
||||
@State private var showAlert = false
|
||||
@State private var alertMessage = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Information Section
|
||||
GroupBox {
|
||||
Label {
|
||||
Text("Add words to help VoiceInk recognize them properly(154 chars max, ~25 words). Works independently of AI enhancement.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} icon: {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
// Input Section
|
||||
HStack(spacing: 8) {
|
||||
TextField("Add word to dictionary", text: $newWord)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 13))
|
||||
.onSubmit { addWord() }
|
||||
|
||||
Button(action: addWord) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(.blue)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(newWord.isEmpty)
|
||||
.help("Add word")
|
||||
}
|
||||
|
||||
// Words List
|
||||
if !dictionaryManager.items.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Dictionary Items (\(dictionaryManager.items.count))")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Toggle words on/off to optimize recognition. Disable unnecessary words to improve local AI model performance.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
ScrollView {
|
||||
let columns = [
|
||||
GridItem(.adaptive(minimum: 240, maximum: .infinity), spacing: 12)
|
||||
]
|
||||
|
||||
LazyVGrid(columns: columns, alignment: .leading, spacing: 12) {
|
||||
ForEach(dictionaryManager.items) { item in
|
||||
DictionaryItemView(item: item) {
|
||||
dictionaryManager.removeWord(item.word)
|
||||
} onToggle: {
|
||||
dictionaryManager.toggleWordState(id: item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.alert("Dictionary", isPresented: $showAlert) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(alertMessage)
|
||||
}
|
||||
.onAppear {
|
||||
dictionaryManager.onDictionaryChanged = { words in
|
||||
whisperState.updateDictionaryWords(words)
|
||||
}
|
||||
// Initial update
|
||||
whisperState.updateDictionaryWords(dictionaryManager.allWords)
|
||||
}
|
||||
}
|
||||
|
||||
private func addWord() {
|
||||
let word = newWord.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !word.isEmpty else { return }
|
||||
|
||||
if dictionaryManager.items.contains(where: { $0.word.lowercased() == word.lowercased() }) {
|
||||
alertMessage = "'\(word)' is already in the dictionary"
|
||||
showAlert = true
|
||||
return
|
||||
}
|
||||
|
||||
dictionaryManager.addWord(word)
|
||||
newWord = ""
|
||||
}
|
||||
}
|
||||
|
||||
struct DictionaryItemView: View {
|
||||
let item: DictionaryItem
|
||||
let onDelete: () -> Void
|
||||
let onToggle: () -> Void
|
||||
@State private var isHovered = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Text(item.word)
|
||||
.font(.system(size: 13))
|
||||
.lineLimit(1)
|
||||
.foregroundColor(item.isEnabled ? .primary : .secondary)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Button(action: onToggle) {
|
||||
Image(systemName: item.isEnabled ? "checkmark.circle.fill" : "circle")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(item.isEnabled ? .green : .secondary)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(item.isEnabled ? "Disable word" : "Enable word")
|
||||
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(isHovered ? .red : .secondary)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove word")
|
||||
}
|
||||
.onHover { hover in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isHovered = hover
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color(.windowBackgroundColor).opacity(0.4))
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(item.isEnabled ? 0.2 : 0.1), lineWidth: 1)
|
||||
}
|
||||
.opacity(item.isEnabled ? 1 : 0.7)
|
||||
.shadow(color: Color.black.opacity(item.isEnabled ? 0.05 : 0), radius: 2, y: 1)
|
||||
}
|
||||
}
|
||||
323
VoiceInk/Views/Dictionary/WordReplacementView.swift
Normal file
@ -0,0 +1,323 @@
|
||||
import SwiftUI
|
||||
|
||||
class WordReplacementManager: ObservableObject {
|
||||
@Published var replacements: [String: String] {
|
||||
didSet {
|
||||
UserDefaults.standard.set(replacements, forKey: "wordReplacements")
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
self.replacements = UserDefaults.standard.dictionary(forKey: "wordReplacements") as? [String: String] ?? [:]
|
||||
}
|
||||
|
||||
func addReplacement(original: String, replacement: String) {
|
||||
replacements[original] = replacement
|
||||
}
|
||||
|
||||
func removeReplacement(original: String) {
|
||||
replacements.removeValue(forKey: original)
|
||||
}
|
||||
}
|
||||
|
||||
struct WordReplacementView: View {
|
||||
@StateObject private var manager = WordReplacementManager()
|
||||
@State private var showAddReplacementModal = false
|
||||
@State private var showAlert = false
|
||||
@State private var alertMessage = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Info Section
|
||||
GroupBox {
|
||||
Label {
|
||||
Text("Define word replacements to automatically replace specific words or phrases during AI enhancement")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(alignment: .leading)
|
||||
} icon: {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Header with action button
|
||||
HStack {
|
||||
Text("Word Replacements")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { showAddReplacementModal = true }) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Add new replacement")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.controlBackgroundColor))
|
||||
|
||||
Divider()
|
||||
|
||||
// Content
|
||||
if manager.replacements.isEmpty {
|
||||
EmptyStateView(showAddModal: $showAddReplacementModal)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(manager.replacements.keys.sorted()), id: \.self) { original in
|
||||
ReplacementRow(
|
||||
original: original,
|
||||
replacement: manager.replacements[original] ?? "",
|
||||
onDelete: { manager.removeReplacement(original: original) }
|
||||
)
|
||||
|
||||
if original != manager.replacements.keys.sorted().last {
|
||||
Divider()
|
||||
.padding(.leading, 32)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(.controlBackgroundColor))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.sheet(isPresented: $showAddReplacementModal) {
|
||||
AddReplacementSheet(manager: manager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyStateView: View {
|
||||
@Binding var showAddModal: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "text.word.spacing")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("No Replacements")
|
||||
.font(.headline)
|
||||
|
||||
Text("Add word replacements to automatically replace text during AI enhancement.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 250)
|
||||
|
||||
Button("Add Replacement") {
|
||||
showAddModal = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.regular)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct AddReplacementSheet: View {
|
||||
@ObservedObject var manager: WordReplacementManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var originalWord = ""
|
||||
@State private var replacementWord = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Button("Cancel", role: .cancel) {
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("Add Word Replacement")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Add") {
|
||||
addReplacement()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(originalWord.isEmpty || replacementWord.isEmpty)
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(.windowBackgroundColor).opacity(0.4))
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Description
|
||||
Text("Define a word or phrase to be automatically replaced during AI enhancement.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
|
||||
// Form Content
|
||||
VStack(spacing: 16) {
|
||||
// Original Text Section
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Original Text")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Required")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
TextField("Enter word or phrase to replace", text: $originalWord)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.body)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Replacement Text Section
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Replacement Text")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Required")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
TextEditor(text: $replacementWord)
|
||||
.font(.body)
|
||||
.frame(height: 100)
|
||||
.padding(8)
|
||||
.background(Color(.textBackgroundColor))
|
||||
.cornerRadius(6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color(.separatorColor), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// Example Section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Example")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Original:")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("my website link")
|
||||
.font(.callout)
|
||||
}
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Replacement:")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("https://tryvoiceink.com")
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.textBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
}
|
||||
.frame(width: 460, height: 480)
|
||||
}
|
||||
|
||||
private func addReplacement() {
|
||||
let trimmedOriginal = originalWord.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedReplacement = replacementWord.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard !trimmedOriginal.isEmpty && !trimmedReplacement.isEmpty else { return }
|
||||
|
||||
manager.addReplacement(original: trimmedOriginal, replacement: trimmedReplacement)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
struct ReplacementRow: View {
|
||||
let original: String
|
||||
let replacement: String
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
// Original Text Container
|
||||
HStack {
|
||||
Text(original)
|
||||
.font(.body)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.textBackgroundColor))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Arrow
|
||||
Image(systemName: "arrow.right")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.system(size: 12))
|
||||
|
||||
// Replacement Text Container
|
||||
HStack {
|
||||
Text(replacement)
|
||||
.font(.body)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.textBackgroundColor))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Delete Button
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(.red)
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove replacement")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
.background(Color(.controlBackgroundColor))
|
||||
}
|
||||
}
|
||||
338
VoiceInk/Views/EnhancementSettingsView.swift
Normal file
@ -0,0 +1,338 @@
|
||||
import SwiftUI
|
||||
|
||||
extension CustomPrompt {
|
||||
func promptIcon(isSelected: Bool, onTap: @escaping () -> Void, onEdit: ((CustomPrompt) -> Void)? = nil, onDelete: ((CustomPrompt) -> Void)? = nil) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
ZStack {
|
||||
// Dynamic background with blur effect
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
gradient: isSelected ?
|
||||
Gradient(colors: [
|
||||
Color.accentColor.opacity(0.9),
|
||||
Color.accentColor.opacity(0.7)
|
||||
]) :
|
||||
Gradient(colors: [
|
||||
Color(NSColor.controlBackgroundColor).opacity(0.95),
|
||||
Color(NSColor.controlBackgroundColor).opacity(0.85)
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
isSelected ?
|
||||
Color.white.opacity(0.3) : Color.white.opacity(0.15),
|
||||
isSelected ?
|
||||
Color.white.opacity(0.1) : Color.white.opacity(0.05)
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
.shadow(
|
||||
color: isSelected ?
|
||||
Color.accentColor.opacity(0.4) : Color.black.opacity(0.1),
|
||||
radius: isSelected ? 10 : 6,
|
||||
x: 0,
|
||||
y: 3
|
||||
)
|
||||
|
||||
// Decorative background elements
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
gradient: Gradient(colors: [
|
||||
isSelected ?
|
||||
Color.white.opacity(0.15) : Color.white.opacity(0.08),
|
||||
Color.clear
|
||||
]),
|
||||
center: .center,
|
||||
startRadius: 1,
|
||||
endRadius: 25
|
||||
)
|
||||
)
|
||||
.frame(width: 50, height: 50)
|
||||
.offset(x: -15, y: -15)
|
||||
.blur(radius: 2)
|
||||
|
||||
// Icon with enhanced effects
|
||||
Image(systemName: icon.rawValue)
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: isSelected ?
|
||||
[Color.white, Color.white.opacity(0.9)] :
|
||||
[Color.primary.opacity(0.9), Color.primary.opacity(0.7)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.shadow(
|
||||
color: isSelected ?
|
||||
Color.white.opacity(0.5) : Color.clear,
|
||||
radius: 4
|
||||
)
|
||||
.shadow(
|
||||
color: isSelected ?
|
||||
Color.accentColor.opacity(0.5) : Color.clear,
|
||||
radius: 3
|
||||
)
|
||||
}
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
// Enhanced title styling
|
||||
Text(title)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(isSelected ?
|
||||
.primary : .secondary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 70)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
.scaleEffect(isSelected ? 1.05 : 1.0)
|
||||
.onTapGesture(perform: onTap)
|
||||
.contextMenu {
|
||||
if !isPredefined && (onEdit != nil || onDelete != nil) {
|
||||
if let onEdit = onEdit {
|
||||
Button {
|
||||
onEdit(self)
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
|
||||
if let onDelete = onDelete {
|
||||
Button(role: .destructive) {
|
||||
onDelete(self)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EnhancementSettingsView: View {
|
||||
@EnvironmentObject private var enhancementService: AIEnhancementService
|
||||
@State private var isEditingPrompt = false
|
||||
@State private var isSettingsExpanded = true
|
||||
@State private var selectedPromptForEdit: CustomPrompt?
|
||||
@State private var isEditingTriggerWord = false
|
||||
@State private var tempTriggerWord = ""
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 32) {
|
||||
// Video CTA Section
|
||||
VideoCTAView(
|
||||
url: "https://dub.sh/promptmode",
|
||||
subtitle: "Learn how to use AI enhancement modes"
|
||||
)
|
||||
|
||||
// Main Settings Sections
|
||||
VStack(spacing: 24) {
|
||||
// Enable/Disable Toggle Section
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Enable Enhancement")
|
||||
.font(.headline)
|
||||
Text("Turn on AI-powered enhancement features")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $enhancementService.isEnhancementEnabled)
|
||||
.toggleStyle(SwitchToggleStyle(tint: .blue))
|
||||
.labelsHidden()
|
||||
.scaleEffect(1.2)
|
||||
}
|
||||
|
||||
HStack(spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Toggle("Clipboard Context", isOn: $enhancementService.useClipboardContext)
|
||||
.toggleStyle(.switch)
|
||||
.disabled(!enhancementService.isEnhancementEnabled)
|
||||
Text("Use text from clipboard to understand the context")
|
||||
.font(.caption)
|
||||
.foregroundColor(enhancementService.isEnhancementEnabled ? .secondary : .secondary.opacity(0.5))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Toggle("Screen Capture", isOn: $enhancementService.useScreenCaptureContext)
|
||||
.toggleStyle(.switch)
|
||||
.disabled(!enhancementService.isEnhancementEnabled)
|
||||
Text("Learn what is on the screen to understand the context")
|
||||
.font(.caption)
|
||||
.foregroundColor(enhancementService.isEnhancementEnabled ? .secondary : .secondary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(.windowBackgroundColor).opacity(0.4))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.blue.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
|
||||
// 1. AI Provider Integration Section
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("AI Provider Integration")
|
||||
.font(.headline)
|
||||
|
||||
APIKeyManagementView()
|
||||
.background(Color(.windowBackgroundColor).opacity(0.4))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.windowBackgroundColor).opacity(0.4))
|
||||
.cornerRadius(10)
|
||||
|
||||
// 3. Enhancement Modes & Assistant Section
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Enhancement Modes & Assistant")
|
||||
.font(.headline)
|
||||
|
||||
// Modes Section
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Enhancement Modes")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Button(action: { isEditingPrompt = true }) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.font(.system(size: 26, weight: .medium))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Circle())
|
||||
.help("Add new mode")
|
||||
}
|
||||
|
||||
if enhancementService.allPrompts.isEmpty {
|
||||
Text("No modes available")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
} else {
|
||||
let columns = [
|
||||
GridItem(.adaptive(minimum: 80, maximum: 100), spacing: 36)
|
||||
]
|
||||
|
||||
LazyVGrid(columns: columns, spacing: 24) {
|
||||
ForEach(enhancementService.allPrompts) { prompt in
|
||||
prompt.promptIcon(
|
||||
isSelected: enhancementService.selectedPromptId == prompt.id,
|
||||
onTap: { withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||
enhancementService.setActivePrompt(prompt)
|
||||
}},
|
||||
onEdit: { selectedPromptForEdit = $0 },
|
||||
onDelete: { enhancementService.deletePrompt($0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Assistant Mode Section
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Assistant Mode")
|
||||
.font(.subheadline)
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
|
||||
Text("Configure how to trigger the AI assistant mode")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Current Trigger:")
|
||||
.font(.subheadline)
|
||||
Text("\"\(enhancementService.assistantTriggerWord)\"")
|
||||
.font(.system(.subheadline, design: .monospaced))
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
|
||||
if isEditingTriggerWord {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
TextField("New trigger word", text: $tempTriggerWord)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 200)
|
||||
|
||||
Button("Save") {
|
||||
enhancementService.assistantTriggerWord = tempTriggerWord
|
||||
isEditingTriggerWord = false
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(tempTriggerWord.isEmpty)
|
||||
|
||||
Button("Cancel") {
|
||||
isEditingTriggerWord = false
|
||||
tempTriggerWord = enhancementService.assistantTriggerWord
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Text("Default: \"hey\"")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
Button("Change Trigger Word") {
|
||||
tempTriggerWord = enhancementService.assistantTriggerWord
|
||||
isEditingTriggerWord = true
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Start with \"\(enhancementService.assistantTriggerWord), \" to use AI assistant mode")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("Instead of enhancing the text, VoiceInk will respond like a conversational AI assistant")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.windowBackgroundColor).opacity(0.4))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.sheet(isPresented: $isEditingPrompt) {
|
||||
PromptEditorView(mode: .add)
|
||||
}
|
||||
.sheet(item: $selectedPromptForEdit) { prompt in
|
||||
PromptEditorView(mode: .edit(prompt))
|
||||
}
|
||||
}
|
||||
}
|
||||
243
VoiceInk/Views/KeyboardShortcutView.swift
Normal file
@ -0,0 +1,243 @@
|
||||
import SwiftUI
|
||||
import KeyboardShortcuts
|
||||
|
||||
struct KeyboardShortcutView: View {
|
||||
let shortcut: KeyboardShortcuts.Shortcut?
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
if let shortcut = shortcut {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(shortcutComponents(from: shortcut), id: \.self) { component in
|
||||
KeyCapView(text: component)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
KeyCapView(text: "Not Set")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func shortcutComponents(from shortcut: KeyboardShortcuts.Shortcut) -> [String] {
|
||||
var components: [String] = []
|
||||
|
||||
// Add modifiers
|
||||
if shortcut.modifiers.contains(.command) { components.append("⌘") }
|
||||
if shortcut.modifiers.contains(.option) { components.append("⌥") }
|
||||
if shortcut.modifiers.contains(.shift) { components.append("⇧") }
|
||||
if shortcut.modifiers.contains(.control) { components.append("⌃") }
|
||||
|
||||
// Add key
|
||||
if let key = shortcut.key {
|
||||
components.append(keyToString(key))
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
private func keyToString(_ key: KeyboardShortcuts.Key) -> String {
|
||||
switch key {
|
||||
case .space: return "Space"
|
||||
case .return: return "↩"
|
||||
case .escape: return "⎋"
|
||||
case .tab: return "⇥"
|
||||
case .delete: return "⌫"
|
||||
case .home: return "↖"
|
||||
case .end: return "↘"
|
||||
case .pageUp: return "⇞"
|
||||
case .pageDown: return "⇟"
|
||||
case .upArrow: return "↑"
|
||||
case .downArrow: return "↓"
|
||||
case .leftArrow: return "←"
|
||||
case .rightArrow: return "→"
|
||||
case .period: return "."
|
||||
case .comma: return ","
|
||||
case .semicolon: return ";"
|
||||
case .quote: return "'"
|
||||
case .slash: return "/"
|
||||
case .backslash: return "\\"
|
||||
case .minus: return "-"
|
||||
case .equal: return "="
|
||||
case .keypad0: return "0"
|
||||
case .keypad1: return "1"
|
||||
case .keypad2: return "2"
|
||||
case .keypad3: return "3"
|
||||
case .keypad4: return "4"
|
||||
case .keypad5: return "5"
|
||||
case .keypad6: return "6"
|
||||
case .keypad7: return "7"
|
||||
case .keypad8: return "8"
|
||||
case .keypad9: return "9"
|
||||
case .a: return "A"
|
||||
case .b: return "B"
|
||||
case .c: return "C"
|
||||
case .d: return "D"
|
||||
case .e: return "E"
|
||||
case .f: return "F"
|
||||
case .g: return "G"
|
||||
case .h: return "H"
|
||||
case .i: return "I"
|
||||
case .j: return "J"
|
||||
case .k: return "K"
|
||||
case .l: return "L"
|
||||
case .m: return "M"
|
||||
case .n: return "N"
|
||||
case .o: return "O"
|
||||
case .p: return "P"
|
||||
case .q: return "Q"
|
||||
case .r: return "R"
|
||||
case .s: return "S"
|
||||
case .t: return "T"
|
||||
case .u: return "U"
|
||||
case .v: return "V"
|
||||
case .w: return "W"
|
||||
case .x: return "X"
|
||||
case .y: return "Y"
|
||||
case .z: return "Z"
|
||||
case .zero: return "0"
|
||||
case .one: return "1"
|
||||
case .two: return "2"
|
||||
case .three: return "3"
|
||||
case .four: return "4"
|
||||
case .five: return "5"
|
||||
case .six: return "6"
|
||||
case .seven: return "7"
|
||||
case .eight: return "8"
|
||||
case .nine: return "9"
|
||||
default:
|
||||
return String(key.rawValue).uppercased()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyCapView: View {
|
||||
let text: String
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var isPressed = false
|
||||
|
||||
private var keyColor: Color {
|
||||
colorScheme == .dark ? Color(white: 0.2) : .white
|
||||
}
|
||||
|
||||
private var surfaceGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
keyColor,
|
||||
keyColor.opacity(0.2)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
|
||||
private var highlightGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
.white.opacity(colorScheme == .dark ? 0.15 : 0.5),
|
||||
.white.opacity(0.0)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .center
|
||||
)
|
||||
}
|
||||
|
||||
private var shadowColor: Color {
|
||||
colorScheme == .dark ? .black : .gray
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 25, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(colorScheme == .dark ? .white : .black)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
ZStack {
|
||||
// Main key surface
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(surfaceGradient)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(highlightGradient)
|
||||
)
|
||||
|
||||
// Border
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
.white.opacity(colorScheme == .dark ? 0.2 : 0.6),
|
||||
shadowColor.opacity(0.3)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
lineWidth: 1
|
||||
)
|
||||
}
|
||||
)
|
||||
// Main shadow
|
||||
.shadow(
|
||||
color: shadowColor.opacity(0.3),
|
||||
radius: 3,
|
||||
x: 0,
|
||||
y: 2
|
||||
)
|
||||
// Bottom edge shadow
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
shadowColor.opacity(0.0),
|
||||
shadowColor.opacity(0.9)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.offset(y: 1)
|
||||
.blur(radius: 2)
|
||||
.mask(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [.clear, .black],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
)
|
||||
.clipped()
|
||||
)
|
||||
// Inner shadow effect
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(
|
||||
Color.white.opacity(colorScheme == .dark ? 0.1 : 0.3),
|
||||
lineWidth: 1
|
||||
)
|
||||
.blur(radius: 1)
|
||||
.offset(x: -1, y: -1)
|
||||
.mask(RoundedRectangle(cornerRadius: 8))
|
||||
)
|
||||
.scaleEffect(isPressed ? 0.95 : 1.0)
|
||||
.animation(.spring(response: 0.2, dampingFraction: 0.6), value: isPressed)
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
isPressed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
isPressed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 20) {
|
||||
KeyboardShortcutView(shortcut: KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder))
|
||||
KeyboardShortcutView(shortcut: nil)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
178
VoiceInk/Views/LanguageSelectionView.swift
Normal file
@ -0,0 +1,178 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LanguageSelectionView: View {
|
||||
@ObservedObject var whisperState: WhisperState
|
||||
@State private var selectedLanguage: String = UserDefaults.standard.string(forKey: "SelectedLanguage") ?? "auto"
|
||||
|
||||
let languages = [
|
||||
"auto": "Auto-detect",
|
||||
"af": "Afrikaans",
|
||||
"am": "Amharic",
|
||||
"ar": "Arabic",
|
||||
"as": "Assamese",
|
||||
"az": "Azerbaijani",
|
||||
"ba": "Bashkir",
|
||||
"be": "Belarusian",
|
||||
"bg": "Bulgarian",
|
||||
"bn": "Bengali",
|
||||
"bo": "Tibetan",
|
||||
"br": "Breton",
|
||||
"bs": "Bosnian",
|
||||
"ca": "Catalan",
|
||||
"cs": "Czech",
|
||||
"cy": "Welsh",
|
||||
"da": "Danish",
|
||||
"de": "German",
|
||||
"el": "Greek",
|
||||
"en": "English",
|
||||
"es": "Spanish",
|
||||
"et": "Estonian",
|
||||
"eu": "Basque",
|
||||
"fa": "Persian",
|
||||
"fi": "Finnish",
|
||||
"fo": "Faroese",
|
||||
"fr": "French",
|
||||
"ga": "Irish",
|
||||
"gl": "Galician",
|
||||
"gu": "Gujarati",
|
||||
"ha": "Hausa",
|
||||
"he": "Hebrew",
|
||||
"hi": "Hindi",
|
||||
"hr": "Croatian",
|
||||
"ht": "Haitian Creole",
|
||||
"hu": "Hungarian",
|
||||
"hy": "Armenian",
|
||||
"id": "Indonesian",
|
||||
"is": "Icelandic",
|
||||
"it": "Italian",
|
||||
"ja": "Japanese",
|
||||
"jw": "Javanese",
|
||||
"ka": "Georgian",
|
||||
"kk": "Kazakh",
|
||||
"km": "Khmer",
|
||||
"kn": "Kannada",
|
||||
"ko": "Korean",
|
||||
"la": "Latin",
|
||||
"lb": "Luxembourgish",
|
||||
"ln": "Lingala",
|
||||
"lo": "Lao",
|
||||
"lt": "Lithuanian",
|
||||
"lv": "Latvian",
|
||||
"mg": "Malagasy",
|
||||
"mi": "Maori",
|
||||
"mk": "Macedonian",
|
||||
"ml": "Malayalam",
|
||||
"mn": "Mongolian",
|
||||
"mr": "Marathi",
|
||||
"ms": "Malay",
|
||||
"mt": "Maltese",
|
||||
"my": "Myanmar",
|
||||
"ne": "Nepali",
|
||||
"nl": "Dutch",
|
||||
"nn": "Norwegian Nynorsk",
|
||||
"no": "Norwegian",
|
||||
"oc": "Occitan",
|
||||
"pa": "Punjabi",
|
||||
"pl": "Polish",
|
||||
"ps": "Pashto",
|
||||
"pt": "Portuguese",
|
||||
"ro": "Romanian",
|
||||
"ru": "Russian",
|
||||
"sa": "Sanskrit",
|
||||
"sd": "Sindhi",
|
||||
"si": "Sinhala",
|
||||
"sk": "Slovak",
|
||||
"sl": "Slovenian",
|
||||
"sn": "Shona",
|
||||
"so": "Somali",
|
||||
"sq": "Albanian",
|
||||
"sr": "Serbian",
|
||||
"su": "Sundanese",
|
||||
"sv": "Swedish",
|
||||
"sw": "Swahili",
|
||||
"ta": "Tamil",
|
||||
"te": "Telugu",
|
||||
"tg": "Tajik",
|
||||
"th": "Thai",
|
||||
"tk": "Turkmen",
|
||||
"tl": "Tagalog",
|
||||
"tr": "Turkish",
|
||||
"tt": "Tatar",
|
||||
"ug": "Uyghur",
|
||||
"uk": "Ukrainian",
|
||||
"ur": "Urdu",
|
||||
"uz": "Uzbek",
|
||||
"vi": "Vietnamese",
|
||||
"yi": "Yiddish",
|
||||
"yo": "Yoruba",
|
||||
"zh": "Chinese"
|
||||
]
|
||||
|
||||
private func updateLanguage(_ language: String) {
|
||||
// Update UI state
|
||||
selectedLanguage = language
|
||||
|
||||
// Persist selection
|
||||
UserDefaults.standard.set(language, forKey: "SelectedLanguage")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Transcription Language")
|
||||
.font(.headline)
|
||||
|
||||
if let currentModel = whisperState.currentModel,
|
||||
let predefinedModel = PredefinedModels.models.first(where: { $0.name == currentModel.name }) {
|
||||
|
||||
if predefinedModel.language == "Multilingual" {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Picker("Select Language", selection: $selectedLanguage) {
|
||||
ForEach(languages.sorted(by: { $0.value < $1.value }), id: \.key) { key, value in
|
||||
Text(value).tag(key)
|
||||
}
|
||||
}
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
.onChange(of: selectedLanguage) { newValue in
|
||||
updateLanguage(newValue)
|
||||
}
|
||||
|
||||
Text("Current model: \(predefinedModel.displayName)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("This model supports multiple languages. You can choose auto-detect or select a specific language.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
// For English-only models, force set language to English
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Language: English")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Current model: \(predefinedModel.displayName)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("This is an English-optimized model and only supports English transcription.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.onAppear {
|
||||
// Ensure English is set when viewing English-only model
|
||||
updateLanguage("en")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("No model selected")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
249
VoiceInk/Views/LicenseManagementView.swift
Normal file
@ -0,0 +1,249 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LicenseManagementView: View {
|
||||
@StateObject private var licenseViewModel = LicenseViewModel()
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// Hero Section
|
||||
heroSection
|
||||
|
||||
// Main Content
|
||||
VStack(spacing: 32) {
|
||||
if case .licensed = licenseViewModel.licenseState {
|
||||
activatedContent
|
||||
} else {
|
||||
purchaseContent
|
||||
}
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
}
|
||||
|
||||
private var heroSection: some View {
|
||||
VStack(spacing: 24) {
|
||||
// App Icon
|
||||
if let appIcon = NSImage(named: "AppIcon") {
|
||||
Image(nsImage: appIcon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 96, height: 96)
|
||||
.cornerRadius(24)
|
||||
.shadow(color: .black.opacity(0.1), radius: 20, x: 0, y: 10)
|
||||
}
|
||||
|
||||
// Title Section
|
||||
VStack(spacing: 16) {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text(licenseViewModel.licenseState == .licensed ? "VoiceInk Pro" : "Upgrade to Pro")
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
}
|
||||
|
||||
Text(licenseViewModel.licenseState == .licensed ?
|
||||
"Thank you for supporting VoiceInk" :
|
||||
"Transform your voice into text with advanced features")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if case .licensed = licenseViewModel.licenseState {
|
||||
HStack(spacing: 40) {
|
||||
Button {
|
||||
if let url = URL(string: "https://voiceink.featurebase.app/changelog") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
featureItem(icon: "list.bullet.clipboard.fill", title: "Changelog", color: .blue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
if let url = URL(string: "https://discord.gg/xryDy57nYD") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
featureItem(icon: "bubble.left.and.bubble.right.fill", title: "Discord", color: .purple)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
if let url = URL(string: "mailto:prakashjoshipax@gmail.com") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
featureItem(icon: "envelope.fill", title: "Email Support", color: .orange)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
if let url = URL(string: "https://voiceink.featurebase.app") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
featureItem(icon: "map.fill", title: "Roadmap", color: .green)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
|
||||
private var purchaseContent: some View {
|
||||
VStack(spacing: 40) {
|
||||
// Purchase Card
|
||||
VStack(spacing: 24) {
|
||||
// Lifetime Access Badge
|
||||
HStack {
|
||||
Image(systemName: "infinity.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(.blue)
|
||||
Text("Buy Once, Own Forever")
|
||||
.font(.headline)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 16)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
// Purchase Button
|
||||
Button(action: {
|
||||
if let url = URL(string: "https://tryvoiceink.com/buy") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Text("Upgrade to VoiceInk Pro")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
// Features Grid
|
||||
HStack(spacing: 40) {
|
||||
featureItem(icon: "bubble.left.and.bubble.right.fill", title: "Priority Support", color: .purple)
|
||||
featureItem(icon: "infinity.circle.fill", title: "Lifetime Access", color: .blue)
|
||||
featureItem(icon: "arrow.up.circle.fill", title: "Free Updates", color: .green)
|
||||
featureItem(icon: "macbook.and.iphone", title: "All Devices", color: .orange)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.padding(32)
|
||||
.background(Color(.windowBackgroundColor).opacity(0.4))
|
||||
.cornerRadius(16)
|
||||
.shadow(color: .black.opacity(0.05), radius: 10)
|
||||
|
||||
// License Activation
|
||||
VStack(spacing: 20) {
|
||||
Text("Already have a license?")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
TextField("Enter your license key", text: $licenseViewModel.licenseKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textCase(.uppercase)
|
||||
|
||||
Button(action: {
|
||||
Task { await licenseViewModel.validateLicense() }
|
||||
}) {
|
||||
if licenseViewModel.isValidating {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Text("Activate")
|
||||
.frame(width: 80)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(licenseViewModel.isValidating)
|
||||
}
|
||||
|
||||
if let message = licenseViewModel.validationMessage {
|
||||
Text(message)
|
||||
.foregroundColor(.red)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
.padding(32)
|
||||
.background(Color(.windowBackgroundColor).opacity(0.4))
|
||||
.cornerRadius(16)
|
||||
.shadow(color: .black.opacity(0.05), radius: 10)
|
||||
}
|
||||
}
|
||||
|
||||
private var activatedContent: some View {
|
||||
VStack(spacing: 32) {
|
||||
// Status Card
|
||||
VStack(spacing: 24) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.green)
|
||||
Text("License Active")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("Active")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(.green))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text("You can use VoiceInk Pro on all your personal devices")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(32)
|
||||
.background(Color(.windowBackgroundColor).opacity(0.4))
|
||||
.cornerRadius(16)
|
||||
.shadow(color: .black.opacity(0.05), radius: 10)
|
||||
|
||||
// Deactivation Card
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("License Management")
|
||||
.font(.headline)
|
||||
|
||||
Button(role: .destructive, action: {
|
||||
licenseViewModel.removeLicense()
|
||||
}) {
|
||||
Label("Deactivate License", systemImage: "xmark.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(32)
|
||||
.background(Color(.windowBackgroundColor).opacity(0.4))
|
||||
.cornerRadius(16)
|
||||
.shadow(color: .black.opacity(0.05), radius: 10)
|
||||
}
|
||||
}
|
||||
|
||||
private func featureItem(icon: String, title: String, color: Color) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(color)
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
55
VoiceInk/Views/LicenseView.swift
Normal file
@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LicenseView: View {
|
||||
@StateObject private var licenseViewModel = LicenseViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 15) {
|
||||
Text("License Management")
|
||||
.font(.headline)
|
||||
|
||||
if case .licensed = licenseViewModel.licenseState {
|
||||
VStack(spacing: 10) {
|
||||
Text("Premium Features Activated")
|
||||
.foregroundColor(.green)
|
||||
|
||||
Button(role: .destructive, action: {
|
||||
licenseViewModel.removeLicense()
|
||||
}) {
|
||||
Text("Remove License")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TextField("Enter License Key", text: $licenseViewModel.licenseKey)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.frame(maxWidth: 300)
|
||||
|
||||
Button(action: {
|
||||
Task {
|
||||
await licenseViewModel.validateLicense()
|
||||
}
|
||||
}) {
|
||||
if licenseViewModel.isValidating {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Activate License")
|
||||
}
|
||||
}
|
||||
.disabled(licenseViewModel.isValidating)
|
||||
}
|
||||
|
||||
if let message = licenseViewModel.validationMessage {
|
||||
Text(message)
|
||||
.foregroundColor(licenseViewModel.licenseState == .licensed ? .green : .red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
struct LicenseView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
LicenseView()
|
||||
}
|
||||
}
|
||||
144
VoiceInk/Views/MenuBarView.swift
Normal file
@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
import LaunchAtLogin
|
||||
|
||||
struct MenuBarView: View {
|
||||
@EnvironmentObject var whisperState: WhisperState
|
||||
@EnvironmentObject var hotkeyManager: HotkeyManager
|
||||
@EnvironmentObject var menuBarManager: MenuBarManager
|
||||
@EnvironmentObject var updaterViewModel: UpdaterViewModel
|
||||
@EnvironmentObject var enhancementService: AIEnhancementService
|
||||
@EnvironmentObject var aiService: AIService
|
||||
@State private var launchAtLoginEnabled = LaunchAtLogin.isEnabled
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Button("Toggle Mini Recorder") {
|
||||
Task {
|
||||
await whisperState.toggleMiniRecorder()
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("AI Enhancement", isOn: $enhancementService.isEnhancementEnabled)
|
||||
|
||||
Menu {
|
||||
ForEach(aiService.connectedProviders, id: \.self) { provider in
|
||||
Button {
|
||||
aiService.selectedProvider = provider
|
||||
} label: {
|
||||
HStack {
|
||||
Text(provider.rawValue)
|
||||
if aiService.selectedProvider == provider {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if aiService.connectedProviders.isEmpty {
|
||||
Text("No providers connected")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Manage AI Providers") {
|
||||
menuBarManager.openMainWindowAndNavigate(to: "Enhancement")
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text("AI Provider: \(aiService.selectedProvider.rawValue)")
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
}
|
||||
|
||||
Menu {
|
||||
ForEach(whisperState.availableModels) { model in
|
||||
Button {
|
||||
Task {
|
||||
await whisperState.setDefaultModel(model)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(PredefinedModels.models.first { $0.name == model.name }?.displayName ?? model.name)
|
||||
if whisperState.currentModel?.name == model.name {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if whisperState.availableModels.isEmpty {
|
||||
Text("No models downloaded")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Manage Models") {
|
||||
menuBarManager.openMainWindowAndNavigate(to: "AI Models")
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Model: \(PredefinedModels.models.first { $0.name == whisperState.currentModel?.name }?.displayName ?? "None")")
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Use Clipboard Context", isOn: $enhancementService.useClipboardContext)
|
||||
.disabled(!enhancementService.isEnhancementEnabled)
|
||||
|
||||
Toggle("Use Screen Context", isOn: $enhancementService.useScreenCaptureContext)
|
||||
.disabled(!enhancementService.isEnhancementEnabled)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("History") {
|
||||
menuBarManager.openMainWindowAndNavigate(to: "History")
|
||||
}
|
||||
|
||||
Button("Settings") {
|
||||
menuBarManager.openMainWindowAndNavigate(to: "Settings")
|
||||
}
|
||||
|
||||
Button(menuBarManager.isMenuBarOnly ? "Show Dock Icon" : "Hide Dock Icon") {
|
||||
menuBarManager.toggleMenuBarOnly()
|
||||
}
|
||||
|
||||
Toggle("Launch at Login", isOn: $launchAtLoginEnabled)
|
||||
.onChange(of: launchAtLoginEnabled) { newValue in
|
||||
LaunchAtLogin.isEnabled = newValue
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Check for Updates") {
|
||||
updaterViewModel.checkForUpdates()
|
||||
}
|
||||
.disabled(!updaterViewModel.canCheckForUpdates)
|
||||
|
||||
Button("About VoiceInk") {
|
||||
NSApplication.shared.orderFrontStandardAboutPanel(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
Button("Help and Support") {
|
||||
openMailForSupport()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Quit VoiceInk") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openMailForSupport() {
|
||||
let subject = "VoiceInk Help & Support"
|
||||
let encodedSubject = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
||||
let mailtoURL = URL(string: "mailto:prakashjoshipax@gmail.com?subject=\(encodedSubject)")!
|
||||
NSWorkspace.shared.open(mailtoURL)
|
||||
}
|
||||
}
|
||||
30
VoiceInk/Views/Metrics/AppIconView.swift
Normal file
@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppIconView: View {
|
||||
var size: CGFloat
|
||||
var cornerRadius: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let appIcon = NSImage(named: NSImage.applicationIconName) {
|
||||
Image(nsImage: appIcon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.stroke(.linearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.5),
|
||||
Color.white.opacity(0.1)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.1), radius: 10, y: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
VoiceInk/Views/Metrics/MetricCard.swift
Normal file
@ -0,0 +1,22 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MetricCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
Text(value)
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color(.controlBackgroundColor))
|
||||
.cornerRadius(10)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
}
|
||||
120
VoiceInk/Views/Metrics/MetricsContent.swift
Normal file
@ -0,0 +1,120 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct MetricsContent: View {
|
||||
let transcriptions: [Transcription]
|
||||
|
||||
var body: some View {
|
||||
if transcriptions.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
TimeEfficiencyView(totalRecordedTime: totalRecordedTime, estimatedTypingTime: estimatedTypingTime)
|
||||
|
||||
metricsGrid
|
||||
|
||||
voiceInkTrendChart
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No Transcriptions Yet")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text("Start recording to see your metrics")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.windowBackgroundColor))
|
||||
}
|
||||
|
||||
private var metricsGrid: some View {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 20) {
|
||||
MetricCard(title: "Words Captured", value: "\(totalWordsTranscribed)")
|
||||
MetricCard(title: "Voice-to-Text Sessions", value: "\(transcriptions.count)")
|
||||
}
|
||||
}
|
||||
|
||||
private var voiceInkTrendChart: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("30-Day VoiceInk Trend")
|
||||
.font(.headline)
|
||||
|
||||
Chart {
|
||||
ForEach(dailyTranscriptionCounts, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Sessions", item.count)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Sessions", item.count)
|
||||
)
|
||||
.foregroundStyle(LinearGradient(colors: [.blue.opacity(0.3), .blue.opacity(0.1)], startPoint: .top, endPoint: .bottom))
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .day, count: 7)) { _ in
|
||||
AxisGridLine()
|
||||
AxisTick()
|
||||
AxisValueLabel(format: .dateTime.day().month(), centered: true)
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisTick()
|
||||
AxisValueLabel()
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.controlBackgroundColor))
|
||||
.cornerRadius(10)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
|
||||
// Computed properties for metrics
|
||||
private var totalWordsTranscribed: Int {
|
||||
transcriptions.reduce(0) { $0 + $1.text.split(separator: " ").count }
|
||||
}
|
||||
|
||||
private var totalRecordedTime: TimeInterval {
|
||||
transcriptions.reduce(0) { $0 + $1.duration }
|
||||
}
|
||||
|
||||
private var estimatedTypingTime: TimeInterval {
|
||||
let averageTypingSpeed: Double = 40 // words per minute
|
||||
let totalWords = Double(totalWordsTranscribed)
|
||||
let estimatedTypingTimeInMinutes = totalWords / averageTypingSpeed
|
||||
return estimatedTypingTimeInMinutes * 60
|
||||
}
|
||||
|
||||
private var dailyTranscriptionCounts: [(date: Date, count: Int)] {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
let thirtyDaysAgo = calendar.date(byAdding: .day, value: -29, to: now)!
|
||||
|
||||
let dailyData = (0..<30).compactMap { dayOffset -> (date: Date, count: Int)? in
|
||||
guard let date = calendar.date(byAdding: .day, value: -dayOffset, to: now) else { return nil }
|
||||
let startOfDay = calendar.startOfDay(for: date)
|
||||
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||
let count = transcriptions.filter { $0.timestamp >= startOfDay && $0.timestamp < endOfDay }.count
|
||||
return (date: startOfDay, count: count)
|
||||
}
|
||||
|
||||
return dailyData.reversed()
|
||||
}
|
||||
}
|
||||
228
VoiceInk/Views/Metrics/MetricsSetupView.swift
Normal file
@ -0,0 +1,228 @@
|
||||
import SwiftUI
|
||||
import KeyboardShortcuts
|
||||
|
||||
struct MetricsSetupView: View {
|
||||
@EnvironmentObject private var whisperState: WhisperState
|
||||
@State private var isAccessibilityEnabled = AXIsProcessTrusted()
|
||||
@State private var isScreenRecordingEnabled = CGPreflightScreenCaptureAccess()
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
VStack(spacing: geometry.size.height * 0.05) {
|
||||
// Header
|
||||
VStack(spacing: geometry.size.height * 0.02) {
|
||||
AppIconView(size: min(90, geometry.size.width * 0.15), cornerRadius: 22)
|
||||
|
||||
VStack(spacing: geometry.size.height * 0.01) {
|
||||
Text("Welcome to VoiceInk")
|
||||
.font(.system(size: min(32, geometry.size.width * 0.05), weight: .bold, design: .rounded))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Complete the setup to get started")
|
||||
.font(.system(size: min(16, geometry.size.width * 0.025)))
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.padding(.top, geometry.size.height * 0.03)
|
||||
|
||||
// Setup Steps
|
||||
VStack(spacing: geometry.size.height * 0.02) {
|
||||
ForEach(0..<4) { index in
|
||||
setupStep(for: index, geometry: geometry)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, geometry.size.width * 0.03)
|
||||
|
||||
Spacer(minLength: geometry.size.height * 0.02)
|
||||
|
||||
// Action Button
|
||||
actionButton
|
||||
.frame(maxWidth: min(600, geometry.size.width * 0.8))
|
||||
|
||||
// Help Text
|
||||
helpText
|
||||
.padding(.bottom, geometry.size.height * 0.03)
|
||||
}
|
||||
.padding(.horizontal, geometry.size.width * 0.05)
|
||||
.frame(minHeight: geometry.size.height)
|
||||
.background {
|
||||
Color(.controlBackgroundColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 500, minHeight: 400)
|
||||
}
|
||||
|
||||
private func setupStep(for index: Int, geometry: GeometryProxy) -> some View {
|
||||
let isCompleted: Bool
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
|
||||
switch index {
|
||||
case 0:
|
||||
isCompleted = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil
|
||||
icon = "command"
|
||||
title = "Set Keyboard Shortcut"
|
||||
description = "Set up a keyboard shortcut to use VoiceInk anywhere"
|
||||
case 1:
|
||||
isCompleted = isAccessibilityEnabled
|
||||
icon = "hand.raised"
|
||||
title = "Enable Accessibility"
|
||||
description = "Allow VoiceInk to paste transcribed text directly at your cursor position"
|
||||
case 2:
|
||||
isCompleted = isScreenRecordingEnabled
|
||||
icon = "video"
|
||||
title = "Enable Screen Recording"
|
||||
description = "Allow VoiceInk to understand context from your screen for transcript Enhancement"
|
||||
default:
|
||||
isCompleted = whisperState.currentModel != nil
|
||||
icon = "arrow.down"
|
||||
title = "Download Model"
|
||||
description = "Choose and download an AI model"
|
||||
}
|
||||
|
||||
return HStack(spacing: geometry.size.width * 0.03) {
|
||||
// Status Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isCompleted ?
|
||||
Color(nsColor: .controlAccentColor).opacity(0.15) :
|
||||
Color(nsColor: .systemRed).opacity(0.15))
|
||||
.frame(width: min(44, geometry.size.width * 0.08), height: min(44, geometry.size.width * 0.08))
|
||||
|
||||
Image(systemName: "\(icon).circle")
|
||||
.font(.system(size: min(24, geometry.size.width * 0.04), weight: .medium))
|
||||
.foregroundColor(isCompleted ? Color(nsColor: .controlAccentColor) : Color(nsColor: .systemRed))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
|
||||
// Text
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: min(16, geometry.size.width * 0.025), weight: .semibold))
|
||||
Text(description)
|
||||
.font(.system(size: min(14, geometry.size.width * 0.022)))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Status indicator
|
||||
if isCompleted {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: min(26, geometry.size.width * 0.045), weight: .semibold))
|
||||
.foregroundColor(Color.green.opacity(0.95))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
} else {
|
||||
Circle()
|
||||
.stroke(Color(nsColor: .systemRed), lineWidth: 2)
|
||||
.frame(width: min(24, geometry.size.width * 0.04), height: min(24, geometry.size.width * 0.04))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, geometry.size.width * 0.03)
|
||||
.padding(.vertical, geometry.size.height * 0.02)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(.windowBackgroundColor))
|
||||
.shadow(
|
||||
color: Color.black.opacity(0.05),
|
||||
radius: 8,
|
||||
x: 0,
|
||||
y: 4
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private var actionButton: some View {
|
||||
Button(action: {
|
||||
if isShortcutAndAccessibilityGranted {
|
||||
openModelManagement()
|
||||
} else {
|
||||
// Handle different permission requests based on which one is missing
|
||||
if KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) == nil {
|
||||
openSettings()
|
||||
} else if !AXIsProcessTrusted() {
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
} else if !CGPreflightScreenCaptureAccess() {
|
||||
CGRequestScreenCaptureAccess()
|
||||
// After requesting, open system preferences as fallback
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 8) {
|
||||
Text(isShortcutAndAccessibilityGranted ? "Download Model" : getActionButtonTitle())
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(nsColor: .controlAccentColor),
|
||||
Color(nsColor: .controlAccentColor).opacity(0.8)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.shadow(
|
||||
color: Color(nsColor: .controlAccentColor).opacity(0.3),
|
||||
radius: 10,
|
||||
y: 5
|
||||
)
|
||||
}
|
||||
|
||||
private func getActionButtonTitle() -> String {
|
||||
if KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) == nil {
|
||||
return "Configure Shortcut"
|
||||
} else if !AXIsProcessTrusted() {
|
||||
return "Enable Accessibility"
|
||||
} else if !CGPreflightScreenCaptureAccess() {
|
||||
return "Enable Screen Recording"
|
||||
}
|
||||
return "Open Settings"
|
||||
}
|
||||
|
||||
private var helpText: some View {
|
||||
Text("Need help? Check the Help menu for support options")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
private var isShortcutAndAccessibilityGranted: Bool {
|
||||
KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil &&
|
||||
AXIsProcessTrusted() &&
|
||||
CGPreflightScreenCaptureAccess()
|
||||
}
|
||||
|
||||
private func openSettings() {
|
||||
NotificationCenter.default.post(
|
||||
name: .navigateToDestination,
|
||||
object: nil,
|
||||
userInfo: ["destination": "Settings"]
|
||||
)
|
||||
}
|
||||
|
||||
private func openModelManagement() {
|
||||
NotificationCenter.default.post(
|
||||
name: .navigateToDestination,
|
||||
object: nil,
|
||||
userInfo: ["destination": "AI Models"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
258
VoiceInk/Views/Metrics/TimeEfficiencyView.swift
Normal file
@ -0,0 +1,258 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TimeEfficiencyView: View {
|
||||
// MARK: - Properties
|
||||
|
||||
private let totalRecordedTime: TimeInterval
|
||||
private let estimatedTypingTime: TimeInterval
|
||||
|
||||
// Computed properties for efficiency metrics
|
||||
private var timeSaved: TimeInterval {
|
||||
estimatedTypingTime - totalRecordedTime
|
||||
}
|
||||
|
||||
private var efficiencyMultiplier: Double {
|
||||
guard totalRecordedTime > 0 else { return 0 }
|
||||
let multiplier = estimatedTypingTime / totalRecordedTime
|
||||
return round(multiplier * 10) / 10 // Round to 1 decimal place
|
||||
}
|
||||
|
||||
private var efficiencyMultiplierFormatted: String {
|
||||
String(format: "%.1fx", efficiencyMultiplier)
|
||||
}
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(totalRecordedTime: TimeInterval, estimatedTypingTime: TimeInterval) {
|
||||
self.totalRecordedTime = totalRecordedTime
|
||||
self.estimatedTypingTime = estimatedTypingTime
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
mainContent
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main Content View
|
||||
|
||||
private var mainContent: some View {
|
||||
VStack(spacing: 24) {
|
||||
headerSection
|
||||
timeComparisonSection
|
||||
bottomSection
|
||||
}
|
||||
.padding(.vertical, 24)
|
||||
.background(backgroundDesign)
|
||||
.overlay(borderOverlay)
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Text("You are")
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
|
||||
Text("\(efficiencyMultiplierFormatted) Faster")
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.foregroundStyle(efficiencyGradient)
|
||||
|
||||
Text("with VoiceInk")
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
}
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
private var timeComparisonSection: some View {
|
||||
HStack(spacing: 16) {
|
||||
TimeBlockView(
|
||||
duration: totalRecordedTime,
|
||||
label: "SPEAKING TIME",
|
||||
icon: "mic.circle.fill",
|
||||
color: .green
|
||||
)
|
||||
|
||||
TimeBlockView(
|
||||
duration: estimatedTypingTime,
|
||||
label: "TYPING TIME",
|
||||
icon: "keyboard.fill",
|
||||
color: .orange
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
private var bottomSection: some View {
|
||||
HStack {
|
||||
timeSavedView
|
||||
Spacer()
|
||||
discordCommunityLink
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
private var timeSavedView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("TIME SAVED")
|
||||
.font(.system(size: 13, weight: .heavy))
|
||||
.tracking(4)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(formatDuration(timeSaved))
|
||||
.font(.system(size: 32, weight: .black, design: .rounded))
|
||||
.foregroundStyle(accentGradient)
|
||||
}
|
||||
}
|
||||
|
||||
private var discordCommunityLink: some View {
|
||||
Link(destination: URL(string: "https://discord.gg/xryDy57nYD")!) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "ellipsis.message.fill")
|
||||
.foregroundStyle(accentGradient)
|
||||
.font(.system(size: 36))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Need Support?")
|
||||
.font(.headline)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text("Got Feature Ideas? We're Listening!")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("JOIN DISCORD")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(6)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(Color.blue.opacity(0.7))
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.blue.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Extension to allow hex color initialization
|
||||
|
||||
// MARK: - Styling Views
|
||||
|
||||
private var backgroundDesign: some View {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
}
|
||||
|
||||
private var borderOverlay: some View {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(
|
||||
.linearGradient(
|
||||
colors: [
|
||||
Color(nsColor: .controlAccentColor).opacity(0.2),
|
||||
Color.clear,
|
||||
Color.clear,
|
||||
Color(nsColor: .controlAccentColor).opacity(0.1)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
lineWidth: 1
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
private var efficiencyGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.green,
|
||||
Color.green.opacity(0.7)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
|
||||
private var accentGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(nsColor: .controlAccentColor),
|
||||
Color(nsColor: .controlAccentColor).opacity(0.8)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Utility Methods
|
||||
|
||||
private func formatDuration(_ duration: TimeInterval) -> String {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = [.hour, .minute, .second]
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.string(from: duration) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Struct
|
||||
|
||||
struct TimeBlockView: View {
|
||||
let duration: TimeInterval
|
||||
let label: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
|
||||
private func formatDuration(_ duration: TimeInterval) -> String {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = [.hour, .minute, .second]
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.string(from: duration) ?? ""
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundColor(color)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(formatDuration(duration))
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: .heavy))
|
||||
.tracking(2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(color.opacity(0.1))
|
||||
)
|
||||
}
|
||||
}
|
||||
60
VoiceInk/Views/MetricsView.swift
Normal file
@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import Charts
|
||||
import KeyboardShortcuts
|
||||
|
||||
struct MetricsView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query(sort: \Transcription.timestamp) private var transcriptions: [Transcription]
|
||||
@EnvironmentObject private var whisperState: WhisperState
|
||||
@EnvironmentObject private var hotkeyManager: HotkeyManager
|
||||
@StateObject private var licenseViewModel = LicenseViewModel()
|
||||
@State private var hasLoadedData = false
|
||||
let skipSetupCheck: Bool
|
||||
|
||||
init(skipSetupCheck: Bool = false) {
|
||||
self.skipSetupCheck = skipSetupCheck
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
// Trial Message
|
||||
if case .trial(let daysRemaining) = licenseViewModel.licenseState {
|
||||
TrialMessageView(
|
||||
message: "You have \(daysRemaining) days left in your trial",
|
||||
type: daysRemaining <= 2 ? .warning : .info
|
||||
)
|
||||
.padding()
|
||||
} else if case .trialExpired = licenseViewModel.licenseState {
|
||||
TrialMessageView(
|
||||
message: "Your trial has expired. Upgrade to continue using VoiceInk",
|
||||
type: .expired
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
|
||||
Group {
|
||||
if skipSetupCheck {
|
||||
MetricsContent(transcriptions: Array(transcriptions))
|
||||
} else if isSetupComplete {
|
||||
MetricsContent(transcriptions: Array(transcriptions))
|
||||
} else {
|
||||
MetricsSetupView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(.windowBackgroundColor))
|
||||
.task {
|
||||
// Ensure the model context is ready
|
||||
hasLoadedData = true
|
||||
}
|
||||
}
|
||||
|
||||
private var isSetupComplete: Bool {
|
||||
hasLoadedData &&
|
||||
whisperState.currentModel != nil &&
|
||||
KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil &&
|
||||
AXIsProcessTrusted() &&
|
||||
CGPreflightScreenCaptureAccess()
|
||||
}
|
||||
}
|
||||
64
VoiceInk/Views/MiniRecorderPanel.swift
Normal file
@ -0,0 +1,64 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
class MiniRecorderPanel: NSPanel {
|
||||
override var canBecomeKey: Bool { false }
|
||||
override var canBecomeMain: Bool { false }
|
||||
|
||||
init(contentRect: NSRect) {
|
||||
super.init(
|
||||
contentRect: contentRect,
|
||||
styleMask: [.nonactivatingPanel, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
self.isFloatingPanel = true
|
||||
self.level = .floating
|
||||
self.backgroundColor = .clear
|
||||
self.isOpaque = false
|
||||
self.hasShadow = false
|
||||
self.isMovableByWindowBackground = true
|
||||
self.hidesOnDeactivate = false
|
||||
self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
|
||||
self.titlebarAppearsTransparent = true
|
||||
self.titleVisibility = .hidden
|
||||
|
||||
self.standardWindowButton(.closeButton)?.isHidden = true
|
||||
|
||||
self.isMovable = true
|
||||
}
|
||||
|
||||
static func calculateWindowMetrics() -> NSRect {
|
||||
guard let screen = NSScreen.main else {
|
||||
return NSRect(x: 0, y: 0, width: 150, height: 34)
|
||||
}
|
||||
|
||||
let width: CGFloat = 150 // Adjusted for new spacing and negative padding
|
||||
let height: CGFloat = 34
|
||||
let padding: CGFloat = 24
|
||||
|
||||
let visibleFrame = screen.visibleFrame
|
||||
|
||||
let xPosition = visibleFrame.midX - (width / 2)
|
||||
let yPosition = visibleFrame.minY + padding
|
||||
|
||||
return NSRect(
|
||||
x: xPosition,
|
||||
y: yPosition,
|
||||
width: width,
|
||||
height: height
|
||||
)
|
||||
}
|
||||
|
||||
func show() {
|
||||
let metrics = MiniRecorderPanel.calculateWindowMetrics()
|
||||
setFrame(metrics, display: true)
|
||||
orderFrontRegardless()
|
||||
}
|
||||
|
||||
func hide(completion: @escaping () -> Void) {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
110
VoiceInk/Views/MiniRecorderView.swift
Normal file
@ -0,0 +1,110 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MiniRecorderView: View {
|
||||
@ObservedObject var whisperState: WhisperState
|
||||
@ObservedObject var audioEngine: AudioEngine
|
||||
@EnvironmentObject var windowManager: MiniWindowManager
|
||||
@State private var showPromptPopover = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if windowManager.isVisible {
|
||||
Capsule()
|
||||
.fill(.clear)
|
||||
.background(
|
||||
ZStack {
|
||||
// Base dark background
|
||||
Color.black.opacity(0.9)
|
||||
|
||||
// Subtle gradient overlay
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black.opacity(0.95),
|
||||
Color(red: 0.15, green: 0.15, blue: 0.15).opacity(0.9)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
// Very subtle visual effect for depth
|
||||
VisualEffectView(material: .hudWindow, blendingMode: .withinWindow)
|
||||
.opacity(0.05)
|
||||
}
|
||||
.clipShape(Capsule())
|
||||
)
|
||||
.overlay {
|
||||
// Subtle inner border
|
||||
Capsule()
|
||||
.strokeBorder(Color.white.opacity(0.1), lineWidth: 0.5)
|
||||
}
|
||||
.overlay {
|
||||
HStack(spacing: 16) {
|
||||
// Record Button
|
||||
NotchRecordButton(
|
||||
isRecording: whisperState.isRecording,
|
||||
isProcessing: whisperState.isProcessing
|
||||
) {
|
||||
Task { await whisperState.toggleRecord() }
|
||||
}
|
||||
.frame(width: 18)
|
||||
.padding(.leading, -4)
|
||||
|
||||
// AI Enhancement Toggle
|
||||
if let enhancementService = whisperState.getEnhancementService() {
|
||||
NotchToggleButton(
|
||||
isEnabled: enhancementService.isEnhancementEnabled,
|
||||
icon: "sparkles",
|
||||
color: .blue
|
||||
) {
|
||||
enhancementService.isEnhancementEnabled.toggle()
|
||||
}
|
||||
.frame(width: 18)
|
||||
.disabled(!enhancementService.isConfigured)
|
||||
}
|
||||
|
||||
// Custom Prompt Toggle and Selector
|
||||
if let enhancementService = whisperState.getEnhancementService() {
|
||||
NotchToggleButton(
|
||||
isEnabled: enhancementService.isEnhancementEnabled,
|
||||
icon: enhancementService.activePrompt?.icon.rawValue ?? "text.badge.checkmark",
|
||||
color: .green
|
||||
) {
|
||||
showPromptPopover.toggle()
|
||||
}
|
||||
.frame(width: 18)
|
||||
.disabled(!enhancementService.isEnhancementEnabled)
|
||||
.popover(isPresented: $showPromptPopover, arrowEdge: .bottom) {
|
||||
NotchPromptPopover(enhancementService: enhancementService)
|
||||
}
|
||||
}
|
||||
|
||||
// Visualizer
|
||||
Group {
|
||||
if whisperState.isProcessing {
|
||||
NotchStaticVisualizer(color: .white)
|
||||
} else {
|
||||
NotchAudioVisualizer(
|
||||
audioLevel: audioEngine.audioLevel,
|
||||
color: .white,
|
||||
isActive: whisperState.isRecording
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(width: 18)
|
||||
.padding(.trailing, -4)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.opacity(windowManager.isVisible ? 1 : 0)
|
||||
.animation(
|
||||
.easeOut(duration: 0.5)
|
||||
.speed(windowManager.isVisible ? 1.0 : 0.8),
|
||||
value: windowManager.isVisible
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Visual Effect View wrapper for NSVisualEffectVie
|
||||
87
VoiceInk/Views/MiniWindowManager.swift
Normal file
@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
class MiniWindowManager: ObservableObject {
|
||||
@Published var isVisible = false
|
||||
private var windowController: NSWindowController?
|
||||
private var miniPanel: MiniRecorderPanel?
|
||||
private let whisperState: WhisperState
|
||||
private let audioEngine: AudioEngine
|
||||
|
||||
init(whisperState: WhisperState, audioEngine: AudioEngine) {
|
||||
self.whisperState = whisperState
|
||||
self.audioEngine = audioEngine
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleHideNotification),
|
||||
name: NSNotification.Name("HideMiniRecorder"),
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
@objc private func handleHideNotification() {
|
||||
hide()
|
||||
}
|
||||
|
||||
func show() {
|
||||
if isVisible { return }
|
||||
|
||||
let activeScreen = NSApp.keyWindow?.screen ?? NSScreen.main ?? NSScreen.screens[0]
|
||||
|
||||
initializeWindow(screen: activeScreen)
|
||||
self.isVisible = true
|
||||
miniPanel?.show()
|
||||
}
|
||||
|
||||
func hide() {
|
||||
guard isVisible else { return }
|
||||
|
||||
withAnimation(.easeOut(duration: 0.5)) {
|
||||
self.isVisible = false
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.miniPanel?.hide { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.deinitializeWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func initializeWindow(screen: NSScreen) {
|
||||
deinitializeWindow()
|
||||
|
||||
let metrics = MiniRecorderPanel.calculateWindowMetrics()
|
||||
let panel = MiniRecorderPanel(contentRect: metrics)
|
||||
|
||||
let miniRecorderView = MiniRecorderView(whisperState: whisperState, audioEngine: audioEngine)
|
||||
.environmentObject(self)
|
||||
|
||||
let hostingController = NSHostingController(rootView: miniRecorderView)
|
||||
panel.contentView = hostingController.view
|
||||
|
||||
self.miniPanel = panel
|
||||
self.windowController = NSWindowController(window: panel)
|
||||
|
||||
panel.orderFrontRegardless()
|
||||
}
|
||||
|
||||
private func deinitializeWindow() {
|
||||
windowController?.close()
|
||||
windowController = nil
|
||||
miniPanel = nil
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
if isVisible {
|
||||
hide()
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
284
VoiceInk/Views/ModelManagementView.swift
Normal file
@ -0,0 +1,284 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ModelManagementView: View {
|
||||
@ObservedObject var whisperState: WhisperState
|
||||
@State private var modelToDelete: WhisperModel?
|
||||
@StateObject private var aiService = AIService()
|
||||
@EnvironmentObject private var enhancementService: AIEnhancementService
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
defaultModelSection
|
||||
languageSelectionSection
|
||||
availableModelsSection
|
||||
}
|
||||
.padding(40)
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.alert(item: $modelToDelete) { model in
|
||||
Alert(
|
||||
title: Text("Delete Model"),
|
||||
message: Text("Are you sure you want to delete the model '\(model.name)'?"),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
Task {
|
||||
await whisperState.deleteModel(model)
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultModelSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Default Model")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
Text(whisperState.currentModel.flatMap { model in
|
||||
PredefinedModels.models.first { $0.name == model.name }?.displayName
|
||||
} ?? "No model selected")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.windowBackgroundColor).opacity(0.4))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private var languageSelectionSection: some View {
|
||||
LanguageSelectionView(whisperState: whisperState)
|
||||
}
|
||||
|
||||
private var availableModelsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("Available Models")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("(\(whisperState.predefinedModels.count))")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 300, maximum: 400), spacing: 16)], spacing: 16) {
|
||||
ForEach(whisperState.predefinedModels) { model in
|
||||
modelCard(for: model)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.windowBackgroundColor).opacity(0.4))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private func modelCard(for model: PredefinedModel) -> some View {
|
||||
let isDownloaded = whisperState.availableModels.contains { $0.name == model.name }
|
||||
let isCurrent = whisperState.currentModel?.name == model.name
|
||||
|
||||
return VStack(alignment: .leading, spacing: 12) {
|
||||
// Model name and details
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(model.displayName)
|
||||
.font(.headline)
|
||||
Text("\(model.size) • \(model.language)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
modelStatusBadge(isDownloaded: isDownloaded, isCurrent: isCurrent)
|
||||
}
|
||||
|
||||
// Description
|
||||
Text(model.description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
// Performance indicators
|
||||
HStack(spacing: 16) {
|
||||
performanceIndicator(label: "Speed", value: model.speed)
|
||||
performanceIndicator(label: "Accuracy", value: model.accuracy)
|
||||
ramUsageLabel(gb: model.ramUsage)
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
HStack {
|
||||
modelActionButton(isDownloaded: isDownloaded, isCurrent: isCurrent, model: model)
|
||||
|
||||
if isDownloaded {
|
||||
Menu {
|
||||
Button(action: {
|
||||
if let downloadedModel = whisperState.availableModels.first(where: { $0.name == model.name }) {
|
||||
modelToDelete = downloadedModel
|
||||
}
|
||||
}) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
if let downloadedModel = whisperState.availableModels.first(where: { $0.name == model.name }) {
|
||||
NSWorkspace.shared.selectFile(downloadedModel.url.path, inFileViewerRootedAtPath: "")
|
||||
}
|
||||
}) {
|
||||
Label("Show in Finder", systemImage: "folder")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.menuStyle(BorderlessButtonMenuStyle())
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.windowBackgroundColor).opacity(0.9))
|
||||
.cornerRadius(10)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isCurrent ? Color.accentColor : Color.gray.opacity(0.2), lineWidth: isCurrent ? 2 : 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func modelStatusBadge(isDownloaded: Bool, isCurrent: Bool) -> some View {
|
||||
Group {
|
||||
if isCurrent {
|
||||
Text("Default")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.green)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(8)
|
||||
} else if isDownloaded {
|
||||
Text("Downloaded")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.indigo)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performanceIndicator(label: String, value: Double) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 2) {
|
||||
ForEach(0..<5) { index in
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(index < Int(value * 5) ? performanceColor(value: value) : Color.secondary.opacity(0.2))
|
||||
.frame(width: 16, height: 8)
|
||||
}
|
||||
}
|
||||
|
||||
Text(String(format: "%.1f", value * 10))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func performanceColor(value: Double) -> Color {
|
||||
switch value {
|
||||
case 0.8...: return .green
|
||||
case 0.6..<0.8: return .yellow
|
||||
case 0.4..<0.6: return .orange
|
||||
default: return .red
|
||||
}
|
||||
}
|
||||
|
||||
private func modelActionButton(isDownloaded: Bool, isCurrent: Bool, model: PredefinedModel) -> some View {
|
||||
Group {
|
||||
if isCurrent {
|
||||
Text("Default Model")
|
||||
.foregroundColor(.white)
|
||||
} else if isDownloaded {
|
||||
Button("Set as Default") {
|
||||
if let downloadedModel = whisperState.availableModels.first(where: { $0.name == model.name }) {
|
||||
Task {
|
||||
await whisperState.setDefaultModel(downloadedModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
} else if whisperState.downloadProgress[model.name] != nil {
|
||||
VStack {
|
||||
ProgressView(value: whisperState.downloadProgress[model.name] ?? 0)
|
||||
.progressViewStyle(LinearProgressViewStyle())
|
||||
.animation(.linear, value: whisperState.downloadProgress[model.name])
|
||||
Text("\(Int((whisperState.downloadProgress[model.name] ?? 0) * 100))%")
|
||||
.font(.caption)
|
||||
.animation(.none)
|
||||
}
|
||||
} else {
|
||||
Button("Download Model") {
|
||||
Task {
|
||||
await whisperState.downloadModel(model)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle(isDownloaded: isDownloaded, isCurrent: isCurrent))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func ramUsageLabel(gb: Double) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("RAM")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(formatRAMSize(gb))
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatRAMSize(_ gb: Double) -> String {
|
||||
if gb >= 1.0 {
|
||||
return String(format: "%.1f GB", gb)
|
||||
} else {
|
||||
return String(format: "%d MB", Int(gb * 1024))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GradientButtonStyle: ButtonStyle {
|
||||
let isDownloaded: Bool
|
||||
let isCurrent: Bool
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 10)
|
||||
.background(
|
||||
Group {
|
||||
if isCurrent {
|
||||
LinearGradient(gradient: Gradient(colors: [Color.green, Color.green.opacity(0.7)]), startPoint: .top, endPoint: .bottom)
|
||||
} else if isDownloaded {
|
||||
LinearGradient(gradient: Gradient(colors: [Color.purple, Color.purple.opacity(0.7)]), startPoint: .top, endPoint: .bottom)
|
||||
} else {
|
||||
LinearGradient(gradient: Gradient(colors: [Color.blue, Color.blue.opacity(0.7)]), startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
}
|
||||
)
|
||||
.cornerRadius(10)
|
||||
.shadow(color: Color.black.opacity(0.2), radius: 5, x: 0, y: 2)
|
||||
.scaleEffect(configuration.isPressed ? 0.95 : 1)
|
||||
.animation(.easeInOut(duration: 0.2), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
192
VoiceInk/Views/NotchRecorderPanel.swift
Normal file
@ -0,0 +1,192 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
class KeyablePanel: NSPanel {
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { true }
|
||||
}
|
||||
|
||||
class NotchRecorderPanel: KeyablePanel {
|
||||
override var canBecomeKey: Bool { false }
|
||||
override var canBecomeMain: Bool { false }
|
||||
|
||||
private var notchMetrics: (width: CGFloat, height: CGFloat) {
|
||||
if let screen = NSScreen.main {
|
||||
let safeAreaInsets = screen.safeAreaInsets
|
||||
|
||||
// Simplified height calculation - matching calculateWindowMetrics
|
||||
let notchHeight: CGFloat
|
||||
if safeAreaInsets.top > 0 {
|
||||
// We're definitely on a notched MacBook
|
||||
notchHeight = safeAreaInsets.top
|
||||
} else {
|
||||
// For external displays or non-notched MacBooks, use system menu bar height
|
||||
notchHeight = NSStatusBar.system.thickness
|
||||
}
|
||||
|
||||
// Get actual notch width from safe area insets
|
||||
let baseNotchWidth: CGFloat = safeAreaInsets.left > 0 ? safeAreaInsets.left * 2 : 200
|
||||
|
||||
// Calculate total width including controls and padding
|
||||
// 16pt padding on each side + space for controls
|
||||
let controlsWidth: CGFloat = 44 // Space for buttons on each side (22 * 2)
|
||||
let paddingWidth: CGFloat = 32 // 16pt on each side
|
||||
let totalWidth = baseNotchWidth + controlsWidth * 2 + paddingWidth
|
||||
|
||||
return (totalWidth, notchHeight)
|
||||
}
|
||||
return (280, 24) // Increased fallback width
|
||||
}
|
||||
|
||||
init(contentRect: NSRect) {
|
||||
let metrics = NotchRecorderPanel.calculateWindowMetrics()
|
||||
|
||||
super.init(
|
||||
contentRect: metrics.frame,
|
||||
styleMask: [.nonactivatingPanel, .fullSizeContentView, .hudWindow],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
self.isFloatingPanel = true
|
||||
self.level = .statusBar + 3
|
||||
self.backgroundColor = .clear
|
||||
self.isOpaque = false
|
||||
self.alphaValue = 1.0
|
||||
self.hasShadow = false
|
||||
self.isMovableByWindowBackground = false
|
||||
self.hidesOnDeactivate = false
|
||||
self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle]
|
||||
|
||||
self.appearance = NSAppearance(named: .darkAqua)
|
||||
self.styleMask.remove(.titled)
|
||||
self.titlebarAppearsTransparent = true
|
||||
self.titleVisibility = .hidden
|
||||
|
||||
// Keep escape key functionality
|
||||
self.standardWindowButton(.closeButton)?.isHidden = true
|
||||
|
||||
// Make window transparent to mouse events except for the content
|
||||
self.ignoresMouseEvents = false
|
||||
self.isMovable = false
|
||||
|
||||
print("NotchRecorderPanel initialized")
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleScreenParametersChange),
|
||||
name: NSApplication.didChangeScreenParametersNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
static func calculateWindowMetrics() -> (frame: NSRect, notchWidth: CGFloat, notchHeight: CGFloat) {
|
||||
guard let screen = NSScreen.main else {
|
||||
return (NSRect(x: 0, y: 0, width: 280, height: 24), 280, 24)
|
||||
}
|
||||
|
||||
let safeAreaInsets = screen.safeAreaInsets
|
||||
|
||||
// Simplified height calculation
|
||||
let notchHeight: CGFloat
|
||||
if safeAreaInsets.top > 0 {
|
||||
// We're definitely on a notched MacBook
|
||||
notchHeight = safeAreaInsets.top
|
||||
} else {
|
||||
// For external displays or non-notched MacBooks, use system menu bar height
|
||||
notchHeight = NSStatusBar.system.thickness
|
||||
}
|
||||
|
||||
// Calculate exact notch width
|
||||
let baseNotchWidth: CGFloat = safeAreaInsets.left > 0 ? safeAreaInsets.left * 2 : 200
|
||||
|
||||
// Calculate total width including controls and padding
|
||||
let controlsWidth: CGFloat = 44 // Space for buttons on each side (22 * 2)
|
||||
let paddingWidth: CGFloat = 32 // 16pt on each side
|
||||
let totalWidth = baseNotchWidth + controlsWidth * 2 + paddingWidth
|
||||
|
||||
// Position exactly at the center
|
||||
let xPosition = screen.frame.midX - (totalWidth / 2)
|
||||
let yPosition = screen.frame.maxY - notchHeight
|
||||
|
||||
let frame = NSRect(
|
||||
x: xPosition,
|
||||
y: yPosition,
|
||||
width: totalWidth,
|
||||
height: notchHeight
|
||||
)
|
||||
|
||||
return (frame, baseNotchWidth, notchHeight)
|
||||
}
|
||||
|
||||
func show() {
|
||||
guard let screen = NSScreen.main else { return }
|
||||
let metrics = NotchRecorderPanel.calculateWindowMetrics()
|
||||
setFrame(metrics.frame, display: true)
|
||||
orderFrontRegardless()
|
||||
}
|
||||
|
||||
func hide(completion: @escaping () -> Void) {
|
||||
completion()
|
||||
}
|
||||
|
||||
@objc private func handleScreenParametersChange() {
|
||||
// Add a small delay to ensure we get the correct screen metrics
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let metrics = NotchRecorderPanel.calculateWindowMetrics()
|
||||
self.setFrame(metrics.frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
|
||||
class NotchRecorderHostingController<Content: View>: NSHostingController<Content> {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.wantsLayer = true
|
||||
view.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
|
||||
// Add visual effect view as background
|
||||
let visualEffect = NSVisualEffectView()
|
||||
visualEffect.material = .dark
|
||||
visualEffect.state = .active
|
||||
visualEffect.blendingMode = .withinWindow
|
||||
visualEffect.wantsLayer = true
|
||||
visualEffect.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.95).cgColor
|
||||
|
||||
// Create a mask layer for the notched shape
|
||||
let maskLayer = CAShapeLayer()
|
||||
let path = CGMutablePath()
|
||||
let bounds = view.bounds
|
||||
let cornerRadius: CGFloat = 10
|
||||
|
||||
// Create the notched path
|
||||
path.move(to: CGPoint(x: bounds.minX, y: bounds.minY))
|
||||
path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.minY))
|
||||
path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.maxY - cornerRadius))
|
||||
path.addQuadCurve(to: CGPoint(x: bounds.maxX - cornerRadius, y: bounds.maxY),
|
||||
control: CGPoint(x: bounds.maxX, y: bounds.maxY))
|
||||
path.addLine(to: CGPoint(x: bounds.minX + cornerRadius, y: bounds.maxY))
|
||||
path.addQuadCurve(to: CGPoint(x: bounds.minX, y: bounds.maxY - cornerRadius),
|
||||
control: CGPoint(x: bounds.minX, y: bounds.maxY))
|
||||
path.closeSubpath()
|
||||
|
||||
maskLayer.path = path
|
||||
visualEffect.layer?.mask = maskLayer
|
||||
|
||||
view.addSubview(visualEffect, positioned: .below, relativeTo: nil)
|
||||
visualEffect.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
visualEffect.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
visualEffect.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
visualEffect.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
visualEffect.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
378
VoiceInk/Views/NotchRecorderView.swift
Normal file
@ -0,0 +1,378 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NotchRecorderView: View {
|
||||
@ObservedObject var whisperState: WhisperState
|
||||
@ObservedObject var audioEngine: AudioEngine
|
||||
@EnvironmentObject var windowManager: NotchWindowManager
|
||||
@State private var isHovering = false
|
||||
@State private var showPromptPopover = false
|
||||
|
||||
private var menuBarHeight: CGFloat {
|
||||
if let screen = NSScreen.main {
|
||||
if screen.safeAreaInsets.top > 0 {
|
||||
return screen.safeAreaInsets.top
|
||||
}
|
||||
return NSApplication.shared.mainMenu?.menuBarHeight ?? NSStatusBar.system.thickness
|
||||
}
|
||||
return NSStatusBar.system.thickness
|
||||
}
|
||||
|
||||
// Calculate exact notch width
|
||||
private var exactNotchWidth: CGFloat {
|
||||
if let screen = NSScreen.main {
|
||||
// On MacBooks with notch, safeAreaInsets.left represents half the notch width
|
||||
if screen.safeAreaInsets.left > 0 {
|
||||
// Multiply by 2 because safeAreaInsets.left is half the notch width
|
||||
return screen.safeAreaInsets.left * 2
|
||||
}
|
||||
// Fallback for non-notched Macs - use a standard width
|
||||
return 200
|
||||
}
|
||||
return 200 // Default fallback
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if windowManager.isVisible {
|
||||
HStack(spacing: 0) {
|
||||
// Left side group with fixed width
|
||||
HStack(spacing: 8) {
|
||||
// Record Button
|
||||
NotchRecordButton(
|
||||
isRecording: whisperState.isRecording,
|
||||
isProcessing: whisperState.isProcessing
|
||||
) {
|
||||
Task { await whisperState.toggleRecord() }
|
||||
}
|
||||
.frame(width: 22)
|
||||
|
||||
// AI Enhancement Toggle
|
||||
if let enhancementService = whisperState.getEnhancementService() {
|
||||
NotchToggleButton(
|
||||
isEnabled: enhancementService.isEnhancementEnabled,
|
||||
icon: "sparkles",
|
||||
color: .blue
|
||||
) {
|
||||
enhancementService.isEnhancementEnabled.toggle()
|
||||
}
|
||||
.frame(width: 22)
|
||||
.disabled(!enhancementService.isConfigured)
|
||||
}
|
||||
}
|
||||
.frame(width: 44) // Fixed width for controls
|
||||
.padding(.leading, 16)
|
||||
|
||||
// Center section with exact notch width
|
||||
Rectangle()
|
||||
.fill(Color.clear)
|
||||
.frame(width: exactNotchWidth)
|
||||
.contentShape(Rectangle()) // Make the entire area tappable
|
||||
|
||||
// Right side group with fixed width
|
||||
HStack(spacing: 8) {
|
||||
// Custom Prompt Toggle and Selector
|
||||
if let enhancementService = whisperState.getEnhancementService() {
|
||||
NotchToggleButton(
|
||||
isEnabled: enhancementService.isEnhancementEnabled,
|
||||
icon: enhancementService.activePrompt?.icon.rawValue ?? "text.badge.checkmark",
|
||||
color: .green
|
||||
) {
|
||||
showPromptPopover.toggle()
|
||||
}
|
||||
.frame(width: 22)
|
||||
.disabled(!enhancementService.isEnhancementEnabled)
|
||||
.popover(isPresented: $showPromptPopover, arrowEdge: .bottom) {
|
||||
NotchPromptPopover(enhancementService: enhancementService)
|
||||
}
|
||||
}
|
||||
|
||||
// Visualizer
|
||||
Group {
|
||||
if whisperState.isProcessing {
|
||||
NotchStaticVisualizer(color: .white)
|
||||
} else {
|
||||
NotchAudioVisualizer(
|
||||
audioLevel: audioEngine.audioLevel,
|
||||
color: .white,
|
||||
isActive: whisperState.isRecording
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(width: 22)
|
||||
}
|
||||
.frame(width: 44) // Fixed width for controls
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
.frame(height: menuBarHeight)
|
||||
.frame(maxWidth: windowManager.isVisible ? .infinity : 0)
|
||||
.background(Color.black)
|
||||
.mask {
|
||||
NotchShape(cornerRadius: 10)
|
||||
}
|
||||
.clipped()
|
||||
.onHover { hovering in
|
||||
isHovering = hovering
|
||||
}
|
||||
.opacity(windowManager.isVisible ? 1 : 0)
|
||||
.animation(
|
||||
.easeOut(duration: 0.5)
|
||||
.speed(windowManager.isVisible ? 1.0 : 0.8), // Slightly slower when hiding
|
||||
value: windowManager.isVisible
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Popover view for prompt selection
|
||||
struct NotchPromptPopover: View {
|
||||
@ObservedObject var enhancementService: AIEnhancementService
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Select Mode")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
|
||||
Divider()
|
||||
.background(Color.white.opacity(0.1))
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(enhancementService.allPrompts) { prompt in
|
||||
NotchPromptRow(prompt: prompt, isSelected: enhancementService.selectedPromptId == prompt.id) {
|
||||
enhancementService.setActivePrompt(prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.frame(width: 180)
|
||||
.frame(maxHeight: 300)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.black)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
}
|
||||
|
||||
// Row view for each prompt
|
||||
struct NotchPromptRow: View {
|
||||
let prompt: CustomPrompt
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: prompt.icon.rawValue)
|
||||
.foregroundColor(isSelected ? .green : .white.opacity(0.8))
|
||||
.font(.system(size: 12))
|
||||
Text(prompt.title)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.font(.system(size: 13))
|
||||
.lineLimit(1)
|
||||
if isSelected {
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.green)
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(isSelected ? Color.white.opacity(0.1) : Color.clear)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
|
||||
// New toggle button component matching the notch aesthetic
|
||||
struct NotchToggleButton: View {
|
||||
let isEnabled: Bool
|
||||
let icon: String
|
||||
let color: Color
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isEnabled ? color.opacity(0.2) : Color(red: 0.4, green: 0.4, blue: 0.45).opacity(0.2))
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(isEnabled ? color : .white.opacity(0.6))
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomScaleModifier: ViewModifier {
|
||||
let scale: CGFloat
|
||||
let opacity: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.scaleEffect(scale, anchor: .center)
|
||||
.opacity(opacity)
|
||||
}
|
||||
}
|
||||
|
||||
// Notch-specific button styles
|
||||
struct NotchRecordButton: View {
|
||||
let isRecording: Bool
|
||||
let isProcessing: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(buttonColor)
|
||||
.frame(width: 22, height: 22)
|
||||
|
||||
if isRecording {
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color.white)
|
||||
.frame(width: 8, height: 8)
|
||||
} else if isProcessing {
|
||||
ProcessingIndicator(color: .white)
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.disabled(isProcessing)
|
||||
}
|
||||
|
||||
private var buttonColor: Color {
|
||||
if isProcessing {
|
||||
return Color(red: 0.4, green: 0.4, blue: 0.45)
|
||||
} else if isRecording {
|
||||
return .red
|
||||
} else {
|
||||
return Color(red: 0.4, green: 0.4, blue: 0.45)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotchAudioVisualizer: View {
|
||||
let audioLevel: CGFloat
|
||||
let color: Color
|
||||
let isActive: Bool
|
||||
|
||||
private let barCount = 5
|
||||
private let minHeight: CGFloat = 3
|
||||
private let maxHeight: CGFloat = 18
|
||||
private let audioThreshold: CGFloat = 0.01
|
||||
|
||||
@State private var barHeights: [CGFloat]
|
||||
|
||||
init(audioLevel: CGFloat, color: Color, isActive: Bool) {
|
||||
self.audioLevel = audioLevel
|
||||
self.color = color
|
||||
self.isActive = isActive
|
||||
_barHeights = State(initialValue: Array(repeating: minHeight, count: 5))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(0..<barCount, id: \.self) { index in
|
||||
NotchVisualizerBar(height: barHeights[index], color: color)
|
||||
}
|
||||
}
|
||||
.onReceive(Timer.publish(every: 0.05, on: .main, in: .common).autoconnect()) { _ in
|
||||
if isActive && audioLevel > audioThreshold {
|
||||
updateBars()
|
||||
} else {
|
||||
resetBars()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateBars() {
|
||||
for i in 0..<barCount {
|
||||
let targetHeight = calculateTargetHeight(for: i)
|
||||
let speed = CGFloat.random(in: 0.4...0.8)
|
||||
barHeights[i] += (targetHeight - barHeights[i]) * speed
|
||||
}
|
||||
}
|
||||
|
||||
private func resetBars() {
|
||||
for i in 0..<barCount {
|
||||
barHeights[i] = minHeight
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateTargetHeight(for index: Int) -> CGFloat {
|
||||
let normalizedLevel = max(0, audioLevel - audioThreshold)
|
||||
let amplifiedLevel = pow(normalizedLevel, 0.6)
|
||||
let baseHeight = amplifiedLevel * maxHeight * 1.7
|
||||
let variation = CGFloat.random(in: -2...2)
|
||||
let positionFactor = CGFloat(index) / CGFloat(barCount - 1)
|
||||
let curve = sin(positionFactor * .pi)
|
||||
|
||||
return max(minHeight, min(baseHeight * curve + variation, maxHeight))
|
||||
}
|
||||
}
|
||||
|
||||
struct NotchVisualizerBar: View {
|
||||
let height: CGFloat
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
color.opacity(0.6),
|
||||
color.opacity(0.8),
|
||||
color
|
||||
]),
|
||||
startPoint: .bottom,
|
||||
endPoint: .top
|
||||
)
|
||||
)
|
||||
.frame(width: 2, height: height)
|
||||
.animation(.spring(response: 0.2, dampingFraction: 0.7, blendDuration: 0), value: height)
|
||||
}
|
||||
}
|
||||
|
||||
struct NotchStaticVisualizer: View {
|
||||
private let barCount = 5
|
||||
private let barHeights: [CGFloat] = [0.7, 0.5, 0.8, 0.4, 0.6]
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(0..<barCount, id: \.self) { index in
|
||||
NotchVisualizerBar(height: barHeights[index] * 18, color: color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProcessingIndicator: View {
|
||||
@State private var rotation: Double = 0
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.trim(from: 0.1, to: 0.9)
|
||||
.stroke(color, lineWidth: 1.5)
|
||||
.frame(width: 12, height: 12)
|
||||
.rotationEffect(.degrees(rotation))
|
||||
.onAppear {
|
||||
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
|
||||
rotation = 360
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
VoiceInk/Views/NotchShape.swift
Normal file
@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NotchShape: Shape {
|
||||
var topCornerRadius: CGFloat {
|
||||
if bottomCornerRadius > 15 {
|
||||
bottomCornerRadius - 5
|
||||
} else {
|
||||
5
|
||||
}
|
||||
}
|
||||
|
||||
var bottomCornerRadius: CGFloat
|
||||
|
||||
init(cornerRadius: CGFloat? = nil) {
|
||||
if cornerRadius == nil {
|
||||
self.bottomCornerRadius = 10
|
||||
} else {
|
||||
self.bottomCornerRadius = cornerRadius!
|
||||
}
|
||||
}
|
||||
|
||||
var animatableData: CGFloat {
|
||||
get { bottomCornerRadius }
|
||||
set { bottomCornerRadius = newValue }
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
// Start from the top left corner
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
// Top left inner curve
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: rect.minX + topCornerRadius, y: topCornerRadius),
|
||||
control: CGPoint(x: rect.minX + topCornerRadius, y: rect.minY)
|
||||
)
|
||||
// Left vertical line
|
||||
path.addLine(to: CGPoint(x: rect.minX + topCornerRadius, y: rect.maxY - bottomCornerRadius))
|
||||
// Bottom left corner
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: rect.minX + topCornerRadius + bottomCornerRadius, y: rect.maxY),
|
||||
control: CGPoint(x: rect.minX + topCornerRadius, y: rect.maxY)
|
||||
)
|
||||
path.addLine(to: CGPoint(x: rect.maxX - topCornerRadius - bottomCornerRadius, y: rect.maxY))
|
||||
// Bottom right corner
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: rect.maxX - topCornerRadius, y: rect.maxY - bottomCornerRadius),
|
||||
control: CGPoint(x: rect.maxX - topCornerRadius, y: rect.maxY)
|
||||
)
|
||||
path.addLine(to: CGPoint(x: rect.maxX - topCornerRadius, y: rect.minY + bottomCornerRadius))
|
||||
|
||||
// Closing the path to top right inner curve
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: rect.maxX, y: rect.minY),
|
||||
control: CGPoint(x: rect.maxX - topCornerRadius, y: rect.minY)
|
||||
)
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
return path
|
||||
}
|
||||
}
|
||||
91
VoiceInk/Views/NotchWindowManager.swift
Normal file
@ -0,0 +1,91 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
class NotchWindowManager: ObservableObject {
|
||||
@Published var isVisible = false
|
||||
private var windowController: NSWindowController?
|
||||
private var notchPanel: NotchRecorderPanel?
|
||||
private let whisperState: WhisperState
|
||||
private let audioEngine: AudioEngine
|
||||
|
||||
init(whisperState: WhisperState, audioEngine: AudioEngine) {
|
||||
self.whisperState = whisperState
|
||||
self.audioEngine = audioEngine
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleHideNotification),
|
||||
name: NSNotification.Name("HideNotchRecorder"),
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
@objc private func handleHideNotification() {
|
||||
hide()
|
||||
}
|
||||
|
||||
func show() {
|
||||
if isVisible { return }
|
||||
|
||||
// Get the active screen from the key window or fallback to main screen
|
||||
let activeScreen = NSApp.keyWindow?.screen ?? NSScreen.main ?? NSScreen.screens[0]
|
||||
|
||||
initializeWindow(screen: activeScreen)
|
||||
self.isVisible = true
|
||||
notchPanel?.show()
|
||||
}
|
||||
|
||||
func hide() {
|
||||
guard isVisible else { return }
|
||||
|
||||
withAnimation(.easeOut(duration: 0.5)) {
|
||||
self.isVisible = false
|
||||
}
|
||||
|
||||
// Wait for animation to complete before cleaning up
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.notchPanel?.hide { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.deinitializeWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func initializeWindow(screen: NSScreen) {
|
||||
deinitializeWindow()
|
||||
|
||||
let metrics = NotchRecorderPanel.calculateWindowMetrics()
|
||||
let panel = NotchRecorderPanel(contentRect: metrics.frame)
|
||||
|
||||
// Create the NotchRecorderView and set it as the content
|
||||
let notchRecorderView = NotchRecorderView(whisperState: whisperState, audioEngine: audioEngine)
|
||||
.environmentObject(self)
|
||||
|
||||
let hostingController = NotchRecorderHostingController(rootView: notchRecorderView)
|
||||
panel.contentView = hostingController.view
|
||||
|
||||
self.notchPanel = panel
|
||||
self.windowController = NSWindowController(window: panel)
|
||||
|
||||
// Only use orderFrontRegardless to show without activating
|
||||
panel.orderFrontRegardless()
|
||||
}
|
||||
|
||||
private func deinitializeWindow() {
|
||||
windowController?.close()
|
||||
windowController = nil
|
||||
notchPanel = nil
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
if isVisible {
|
||||
hide()
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
230
VoiceInk/Views/Onboarding/OnboardingModelDownloadView.swift
Normal file
@ -0,0 +1,230 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingModelDownloadView: View {
|
||||
@Binding var hasCompletedOnboarding: Bool
|
||||
@EnvironmentObject private var whisperState: WhisperState
|
||||
@State private var scale: CGFloat = 0.8
|
||||
@State private var opacity: CGFloat = 0
|
||||
@State private var isDownloading = false
|
||||
@State private var isModelSet = false
|
||||
@State private var showTutorial = false
|
||||
|
||||
private let turboModel = PredefinedModels.models.first { $0.name == "ggml-large-v3-turbo-q5_0" }!
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
GeometryReader { geometry in
|
||||
// Reusable background
|
||||
OnboardingBackgroundView()
|
||||
|
||||
VStack(spacing: 40) {
|
||||
// Model icon and title
|
||||
VStack(spacing: 30) {
|
||||
// Model icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.accentColor.opacity(0.1))
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
if isModelSet {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(.accentColor)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
} else {
|
||||
Image(systemName: "brain")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.scaleEffect(scale)
|
||||
.opacity(opacity)
|
||||
|
||||
// Title and description
|
||||
VStack(spacing: 12) {
|
||||
Text("Download AI Model")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text("We'll download the optimized model to get you started.")
|
||||
.font(.body)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.scaleEffect(scale)
|
||||
.opacity(opacity)
|
||||
}
|
||||
|
||||
// Model card - Centered and compact
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Model name and details
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
Text(turboModel.displayName)
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
Text("\(turboModel.size) • \(turboModel.language)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Divider()
|
||||
.background(Color.white.opacity(0.1))
|
||||
|
||||
// Performance indicators in a more compact layout
|
||||
HStack(spacing: 20) {
|
||||
performanceIndicator(label: "Speed", value: turboModel.speed)
|
||||
performanceIndicator(label: "Accuracy", value: turboModel.accuracy)
|
||||
ramUsageLabel(gb: turboModel.ramUsage)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
// Download progress
|
||||
if isDownloading {
|
||||
VStack(spacing: 8) {
|
||||
ProgressView(value: whisperState.downloadProgress[turboModel.name] ?? 0)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(.white)
|
||||
Text("\(Int((whisperState.downloadProgress[turboModel.name] ?? 0) * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: min(geometry.size.width * 0.6, 400))
|
||||
.background(Color.black.opacity(0.3))
|
||||
.cornerRadius(16)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(Color.white.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
.scaleEffect(scale)
|
||||
.opacity(opacity)
|
||||
|
||||
// Action buttons
|
||||
VStack(spacing: 16) {
|
||||
Button(action: handleAction) {
|
||||
Text(getButtonTitle())
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 200, height: 50)
|
||||
.background(Color.accentColor)
|
||||
.cornerRadius(25)
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
.disabled(isDownloading)
|
||||
|
||||
if !isModelSet {
|
||||
SkipButton(text: "Skip for now") {
|
||||
withAnimation {
|
||||
showTutorial = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(opacity)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(width: min(geometry.size.width * 0.8, 600))
|
||||
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
||||
}
|
||||
|
||||
if showTutorial {
|
||||
OnboardingTutorialView(hasCompletedOnboarding: $hasCompletedOnboarding)
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
animateIn()
|
||||
checkModelStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func animateIn() {
|
||||
withAnimation(.spring(response: 0.6, dampingFraction: 0.7)) {
|
||||
scale = 1
|
||||
opacity = 1
|
||||
}
|
||||
}
|
||||
|
||||
private func checkModelStatus() {
|
||||
if let model = whisperState.availableModels.first(where: { $0.name == turboModel.name }) {
|
||||
isModelSet = whisperState.currentModel?.name == model.name
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAction() {
|
||||
if isModelSet {
|
||||
withAnimation {
|
||||
showTutorial = true
|
||||
}
|
||||
} else if let model = whisperState.availableModels.first(where: { $0.name == turboModel.name }) {
|
||||
Task {
|
||||
await whisperState.setDefaultModel(model)
|
||||
withAnimation {
|
||||
isModelSet = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withAnimation {
|
||||
isDownloading = true
|
||||
}
|
||||
Task {
|
||||
await whisperState.downloadModel(turboModel)
|
||||
if let model = whisperState.availableModels.first(where: { $0.name == turboModel.name }) {
|
||||
await whisperState.setDefaultModel(model)
|
||||
withAnimation {
|
||||
isModelSet = true
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getButtonTitle() -> String {
|
||||
if isModelSet {
|
||||
return "Continue"
|
||||
} else if isDownloading {
|
||||
return "Downloading..."
|
||||
} else if whisperState.availableModels.contains(where: { $0.name == turboModel.name }) {
|
||||
return "Set as Default"
|
||||
} else {
|
||||
return "Download Model"
|
||||
}
|
||||
}
|
||||
|
||||
private func performanceIndicator(label: String, value: Double) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<5) { index in
|
||||
Circle()
|
||||
.fill(Double(index) / 5.0 <= value ? Color.accentColor : Color.white.opacity(0.2))
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ramUsageLabel(gb: Double) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("RAM")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
|
||||
Text(String(format: "%.1f GB", gb))
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
295
VoiceInk/Views/Onboarding/OnboardingPermissionsView.swift
Normal file
@ -0,0 +1,295 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import KeyboardShortcuts
|
||||
|
||||
struct OnboardingPermission: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
let description: String
|
||||
let icon: String
|
||||
let type: PermissionType
|
||||
|
||||
enum PermissionType {
|
||||
case microphone
|
||||
case accessibility
|
||||
case screenRecording
|
||||
case keyboardShortcut
|
||||
|
||||
var systemName: String {
|
||||
switch self {
|
||||
case .microphone: return "mic"
|
||||
case .accessibility: return "accessibility"
|
||||
case .screenRecording: return "rectangle.inset.filled.and.person.filled"
|
||||
case .keyboardShortcut: return "keyboard"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingPermissionsView: View {
|
||||
@Binding var hasCompletedOnboarding: Bool
|
||||
@EnvironmentObject private var hotkeyManager: HotkeyManager
|
||||
@State private var currentPermissionIndex = 0
|
||||
@State private var permissionStates: [Bool] = [false, false, false, false]
|
||||
@State private var showAnimation = false
|
||||
@State private var scale: CGFloat = 0.8
|
||||
@State private var opacity: CGFloat = 0
|
||||
@State private var showModelDownload = false
|
||||
|
||||
private let permissions: [OnboardingPermission] = [
|
||||
OnboardingPermission(
|
||||
title: "Microphone Access",
|
||||
description: "Enable your microphone to start speaking and converting your voice to text instantly.",
|
||||
icon: "waveform",
|
||||
type: .microphone
|
||||
),
|
||||
OnboardingPermission(
|
||||
title: "Accessibility Access",
|
||||
description: "Allow VoiceInk to help you type anywhere in your Mac.",
|
||||
icon: "accessibility",
|
||||
type: .accessibility
|
||||
),
|
||||
OnboardingPermission(
|
||||
title: "Screen Recording",
|
||||
description: "This helps to improve the accuracy of transcription.",
|
||||
icon: "rectangle.inset.filled.and.person.filled",
|
||||
type: .screenRecording
|
||||
),
|
||||
OnboardingPermission(
|
||||
title: "Keyboard Shortcut",
|
||||
description: "Set up a keyboard shortcut to quickly access VoiceInk from anywhere.",
|
||||
icon: "keyboard",
|
||||
type: .keyboardShortcut
|
||||
)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Reusable background
|
||||
OnboardingBackgroundView()
|
||||
|
||||
VStack(spacing: 40) {
|
||||
// Progress indicator
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<permissions.count, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index <= currentPermissionIndex ? Color.accentColor : Color.white.opacity(0.1))
|
||||
.frame(width: 8, height: 8)
|
||||
.scaleEffect(index == currentPermissionIndex ? 1.2 : 1.0)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: currentPermissionIndex)
|
||||
}
|
||||
}
|
||||
.padding(.top, 40)
|
||||
|
||||
// Current permission card
|
||||
VStack(spacing: 30) {
|
||||
// Permission icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.accentColor.opacity(0.1))
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
if permissionStates[currentPermissionIndex] {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(.accentColor)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
} else {
|
||||
Image(systemName: permissions[currentPermissionIndex].icon)
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.scaleEffect(scale)
|
||||
.opacity(opacity)
|
||||
|
||||
// Permission text
|
||||
VStack(spacing: 12) {
|
||||
Text(permissions[currentPermissionIndex].title)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text(permissions[currentPermissionIndex].description)
|
||||
.font(.body)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.scaleEffect(scale)
|
||||
.opacity(opacity)
|
||||
|
||||
// Keyboard shortcut recorder (only shown for keyboard shortcut step)
|
||||
if permissions[currentPermissionIndex].type == .keyboardShortcut {
|
||||
VStack(spacing: 16) {
|
||||
if hotkeyManager.isShortcutConfigured {
|
||||
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) {
|
||||
KeyboardShortcutView(shortcut: shortcut)
|
||||
.scaleEffect(1.2)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 16) {
|
||||
KeyboardShortcuts.Recorder("Set Shortcut:", name: .toggleMiniRecorder) { newShortcut in
|
||||
if newShortcut != nil {
|
||||
permissionStates[currentPermissionIndex] = true
|
||||
} else {
|
||||
permissionStates[currentPermissionIndex] = false
|
||||
}
|
||||
hotkeyManager.updateShortcutStatus()
|
||||
}
|
||||
.controlSize(.large)
|
||||
|
||||
SkipButton(text: "Skip for now") {
|
||||
moveToNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
.scaleEffect(scale)
|
||||
.opacity(opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 400)
|
||||
.padding(.vertical, 40)
|
||||
|
||||
// Action buttons
|
||||
VStack(spacing: 16) {
|
||||
Button(action: requestPermission) {
|
||||
Text(getButtonTitle())
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 200, height: 50)
|
||||
.background(Color.accentColor)
|
||||
.cornerRadius(25)
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
|
||||
if !permissionStates[currentPermissionIndex] && permissions[currentPermissionIndex].type != .keyboardShortcut {
|
||||
SkipButton(text: "Skip for now") {
|
||||
moveToNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(opacity)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
if showModelDownload {
|
||||
OnboardingModelDownloadView(hasCompletedOnboarding: $hasCompletedOnboarding)
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
checkExistingPermissions()
|
||||
animateIn()
|
||||
}
|
||||
}
|
||||
|
||||
private func animateIn() {
|
||||
withAnimation(.spring(response: 0.6, dampingFraction: 0.7)) {
|
||||
scale = 1
|
||||
opacity = 1
|
||||
}
|
||||
}
|
||||
|
||||
private func resetAnimation() {
|
||||
scale = 0.8
|
||||
opacity = 0
|
||||
animateIn()
|
||||
}
|
||||
|
||||
private func checkExistingPermissions() {
|
||||
// Check microphone permission
|
||||
permissionStates[0] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
|
||||
// Check accessibility permission
|
||||
permissionStates[1] = AXIsProcessTrusted()
|
||||
|
||||
// Check screen recording permission
|
||||
permissionStates[2] = CGPreflightScreenCaptureAccess()
|
||||
|
||||
// Check keyboard shortcut
|
||||
permissionStates[3] = hotkeyManager.isShortcutConfigured
|
||||
}
|
||||
|
||||
private func requestPermission() {
|
||||
if permissionStates[currentPermissionIndex] {
|
||||
moveToNext()
|
||||
return
|
||||
}
|
||||
|
||||
switch permissions[currentPermissionIndex].type {
|
||||
case .microphone:
|
||||
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
||||
DispatchQueue.main.async {
|
||||
permissionStates[currentPermissionIndex] = granted
|
||||
if granted {
|
||||
withAnimation {
|
||||
showAnimation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .accessibility:
|
||||
let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
|
||||
AXIsProcessTrustedWithOptions(options)
|
||||
|
||||
// Start checking for permission status
|
||||
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
|
||||
if AXIsProcessTrusted() {
|
||||
timer.invalidate()
|
||||
permissionStates[currentPermissionIndex] = true
|
||||
withAnimation {
|
||||
showAnimation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .screenRecording:
|
||||
// Launch system preferences for screen recording
|
||||
let prefpaneURL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")!
|
||||
NSWorkspace.shared.open(prefpaneURL)
|
||||
|
||||
// Start checking for permission status
|
||||
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
|
||||
if CGPreflightScreenCaptureAccess() {
|
||||
timer.invalidate()
|
||||
permissionStates[currentPermissionIndex] = true
|
||||
withAnimation {
|
||||
showAnimation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .keyboardShortcut:
|
||||
// The keyboard shortcut is handled by the KeyboardShortcuts.Recorder
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func moveToNext() {
|
||||
if currentPermissionIndex < permissions.count - 1 {
|
||||
withAnimation {
|
||||
currentPermissionIndex += 1
|
||||
resetAnimation()
|
||||
}
|
||||
} else {
|
||||
withAnimation {
|
||||
showModelDownload = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getButtonTitle() -> String {
|
||||
if permissions[currentPermissionIndex].type == .keyboardShortcut {
|
||||
return permissionStates[currentPermissionIndex] ? "Continue" : "Set Shortcut"
|
||||
}
|
||||
return permissionStates[currentPermissionIndex] ? "Continue" : "Enable Access"
|
||||
}
|
||||
}
|
||||
191
VoiceInk/Views/Onboarding/OnboardingTutorialView.swift
Normal file
@ -0,0 +1,191 @@
|
||||
import SwiftUI
|
||||
import KeyboardShortcuts
|
||||
|
||||
struct OnboardingTutorialView: View {
|
||||
@Binding var hasCompletedOnboarding: Bool
|
||||
@EnvironmentObject private var hotkeyManager: HotkeyManager
|
||||
@EnvironmentObject private var whisperState: WhisperState
|
||||
@State private var scale: CGFloat = 0.8
|
||||
@State private var opacity: CGFloat = 0
|
||||
@State private var transcribedText: String = ""
|
||||
@State private var isTextFieldFocused: Bool = false
|
||||
@State private var showingShortcutHint: Bool = true
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Reusable background
|
||||
OnboardingBackgroundView()
|
||||
|
||||
HStack(spacing: 0) {
|
||||
// Left side - Tutorial instructions
|
||||
VStack(alignment: .leading, spacing: 40) {
|
||||
// Title and description
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Try It Out!")
|
||||
.font(.system(size: 44, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text("Let's test your VoiceInk setup.")
|
||||
.font(.system(size: 24, weight: .medium, design: .rounded))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.lineSpacing(4)
|
||||
}
|
||||
|
||||
// Keyboard shortcut display
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Your Shortcut")
|
||||
.font(.system(size: 28, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
|
||||
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) {
|
||||
KeyboardShortcutView(shortcut: shortcut)
|
||||
.scaleEffect(1.2)
|
||||
}
|
||||
}
|
||||
|
||||
// Instructions
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
ForEach(1...4, id: \.self) { step in
|
||||
instructionStep(number: step, text: getInstructionText(for: step))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Continue button
|
||||
Button(action: {
|
||||
hasCompletedOnboarding = true
|
||||
}) {
|
||||
Text("Complete Setup")
|
||||
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 200, height: 50)
|
||||
.background(Color.accentColor)
|
||||
.cornerRadius(25)
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
.opacity(transcribedText.isEmpty ? 0.5 : 1)
|
||||
.disabled(transcribedText.isEmpty)
|
||||
|
||||
SkipButton(text: "Skip for now") {
|
||||
hasCompletedOnboarding = true
|
||||
}
|
||||
}
|
||||
.padding(60)
|
||||
.frame(width: geometry.size.width * 0.5)
|
||||
|
||||
// Right side - Interactive area
|
||||
VStack {
|
||||
// Magical text editor area
|
||||
ZStack {
|
||||
// Glowing background
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color.black.opacity(0.4))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(Color.white.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
.overlay(
|
||||
// Subtle gradient overlay
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.accentColor.opacity(0.05),
|
||||
Color.black.opacity(0.1)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.shadow(color: Color.accentColor.opacity(0.1), radius: 15, x: 0, y: 0)
|
||||
|
||||
// Text editor with custom styling
|
||||
TextEditor(text: $transcribedText)
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
.focused($isFocused)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.clear)
|
||||
.foregroundColor(.white)
|
||||
.padding(20)
|
||||
|
||||
// Placeholder text with magical appearance
|
||||
if transcribedText.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "wand.and.stars")
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(.white.opacity(0.3))
|
||||
|
||||
Text("Click here and start speaking...")
|
||||
.font(.system(size: 28, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding()
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
// Subtle animated border
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.accentColor.opacity(isFocused ? 0.4 : 0.1),
|
||||
Color.accentColor.opacity(isFocused ? 0.2 : 0.05)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
lineWidth: 1
|
||||
)
|
||||
.animation(.easeInOut(duration: 0.3), value: isFocused)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.padding(60)
|
||||
.frame(width: geometry.size.width * 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
animateIn()
|
||||
isFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
private func getInstructionText(for step: Int) -> String {
|
||||
switch step {
|
||||
case 1: return "Click the text area on the right"
|
||||
case 2: return "Press your keyboard shortcut"
|
||||
case 3: return "Speak something"
|
||||
case 4: return "Press your keyboard shortcut again"
|
||||
default: return ""
|
||||
}
|
||||
}
|
||||
|
||||
private func instructionStep(number: Int, text: String) -> some View {
|
||||
HStack(spacing: 20) {
|
||||
Text("\(number)")
|
||||
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Circle().fill(Color.accentColor.opacity(0.2)))
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.accentColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
|
||||
Text(text)
|
||||
.font(.system(size: 18, weight: .medium, design: .rounded))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.lineSpacing(4)
|
||||
}
|
||||
}
|
||||
|
||||
private func animateIn() {
|
||||
withAnimation(.spring(response: 0.6, dampingFraction: 0.7)) {
|
||||
scale = 1
|
||||
opacity = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
373
VoiceInk/Views/Onboarding/OnboardingView.swift
Normal file
@ -0,0 +1,373 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingView: View {
|
||||
@Binding var hasCompletedOnboarding: Bool
|
||||
@State private var textOpacity: CGFloat = 0
|
||||
@State private var showSecondaryElements = false
|
||||
@State private var showPermissions = false
|
||||
|
||||
// Animation timing
|
||||
private let animationDelay = 0.2
|
||||
private let textAnimationDuration = 0.6
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Reusable background
|
||||
OnboardingBackgroundView()
|
||||
|
||||
// Content container
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
// Content Area
|
||||
VStack(spacing: 60) {
|
||||
Spacer()
|
||||
.frame(height: 40)
|
||||
|
||||
// Title and subtitle
|
||||
VStack(spacing: 16) {
|
||||
Text("Welcome to the Future of Typing")
|
||||
.font(.system(size: min(geometry.size.width * 0.055, 42), weight: .bold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
.opacity(textOpacity)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
Text("A New Way to Type")
|
||||
.font(.system(size: min(geometry.size.width * 0.032, 24), weight: .medium, design: .rounded))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.opacity(textOpacity)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
if showSecondaryElements {
|
||||
// Typewriter roles animation
|
||||
TypewriterRoles()
|
||||
.frame(height: 160)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
}
|
||||
.padding(.top, geometry.size.height * 0.15)
|
||||
|
||||
Spacer(minLength: geometry.size.height * 0.2)
|
||||
|
||||
// Bottom navigation
|
||||
if showSecondaryElements {
|
||||
VStack(spacing: 20) {
|
||||
Button(action: {
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
|
||||
showPermissions = true
|
||||
}
|
||||
}) {
|
||||
Text("Get Started")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(.black)
|
||||
.frame(width: min(geometry.size.width * 0.3, 200), height: 50)
|
||||
.background(Color.white)
|
||||
.cornerRadius(25)
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
|
||||
SkipButton(text: "Skip Tour") {
|
||||
hasCompletedOnboarding = true
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 35)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if showPermissions {
|
||||
OnboardingPermissionsView(hasCompletedOnboarding: $hasCompletedOnboarding)
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
startAnimations()
|
||||
}
|
||||
}
|
||||
|
||||
private func startAnimations() {
|
||||
// Text fade in
|
||||
withAnimation(.easeOut(duration: textAnimationDuration).delay(animationDelay)) {
|
||||
textOpacity = 1
|
||||
}
|
||||
|
||||
// Show secondary elements
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + animationDelay * 3) {
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||||
showSecondaryElements = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Views
|
||||
struct TypewriterRoles: View {
|
||||
private let roles = [
|
||||
"Your Writing Assistant",
|
||||
"Your Vibe-Coding Assistant",
|
||||
"Works Everywhere on Mac with a click",
|
||||
"100% offline & private",
|
||||
|
||||
]
|
||||
|
||||
@State private var displayedText = ""
|
||||
@State private var currentIndex = 0
|
||||
@State private var showCursor = true
|
||||
@State private var isTyping = false
|
||||
@State private var isDeleting = false
|
||||
|
||||
// Animation timing
|
||||
private let typingSpeed = 0.05 // Time between each character
|
||||
private let deleteSpeed = 0.03 // Faster deletion
|
||||
private let pauseDuration = 1.0 // How long to show completed text
|
||||
private let cursorBlinkSpeed = 0.6
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack(spacing: 0) {
|
||||
Text(displayedText)
|
||||
.font(.system(size: 42, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.accentColor,
|
||||
Color.accentColor.opacity(0.8),
|
||||
Color.white.opacity(0.9)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
|
||||
// Blinking cursor
|
||||
Text("|")
|
||||
.font(.system(size: 42, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.accentColor,
|
||||
Color.accentColor.opacity(0.8)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.opacity(showCursor ? 1 : 0)
|
||||
.animation(.easeInOut(duration: cursorBlinkSpeed).repeatForever(), value: showCursor)
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.shadow(color: Color.accentColor.opacity(0.5), radius: 15, x: 0, y: 0)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.onAppear {
|
||||
startTypingAnimation()
|
||||
// Start cursor blinking
|
||||
withAnimation(.easeInOut(duration: cursorBlinkSpeed).repeatForever()) {
|
||||
showCursor.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startTypingAnimation() {
|
||||
guard currentIndex < roles.count else { return }
|
||||
let targetText = roles[currentIndex]
|
||||
isTyping = true
|
||||
|
||||
// Type out the text
|
||||
var charIndex = 0
|
||||
func typeNextCharacter() {
|
||||
guard charIndex < targetText.count else {
|
||||
// Typing complete, pause then delete
|
||||
isTyping = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + pauseDuration) {
|
||||
startDeletingAnimation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let nextChar = String(targetText[targetText.index(targetText.startIndex, offsetBy: charIndex)])
|
||||
displayedText += nextChar
|
||||
charIndex += 1
|
||||
|
||||
// Schedule next character
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + typingSpeed) {
|
||||
typeNextCharacter()
|
||||
}
|
||||
}
|
||||
|
||||
typeNextCharacter()
|
||||
}
|
||||
|
||||
private func startDeletingAnimation() {
|
||||
isDeleting = true
|
||||
|
||||
func deleteNextCharacter() {
|
||||
guard !displayedText.isEmpty else {
|
||||
isDeleting = false
|
||||
currentIndex = (currentIndex + 1) % roles.count
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
startTypingAnimation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
displayedText.removeLast()
|
||||
|
||||
// Schedule next deletion
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + deleteSpeed) {
|
||||
deleteNextCharacter()
|
||||
}
|
||||
}
|
||||
|
||||
deleteNextCharacter()
|
||||
}
|
||||
}
|
||||
|
||||
struct SkipButton: View {
|
||||
let text: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundColor(.white.opacity(0.2))
|
||||
.onTapGesture(perform: action)
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingBackgroundView: View {
|
||||
@State private var glowOpacity: CGFloat = 0
|
||||
@State private var glowScale: CGFloat = 0.8
|
||||
@State private var particlesActive = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Base background with black gradient
|
||||
Color.black
|
||||
.overlay(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black,
|
||||
Color.black.opacity(0.8),
|
||||
Color.black.opacity(0.6)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
// Animated glow effect
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: min(geometry.size.width, geometry.size.height) * 0.4)
|
||||
.blur(radius: 100)
|
||||
.opacity(glowOpacity)
|
||||
.scaleEffect(glowScale)
|
||||
.position(
|
||||
x: geometry.size.width * 0.5,
|
||||
y: geometry.size.height * 0.3
|
||||
)
|
||||
|
||||
// Enhanced particles with reduced opacity
|
||||
ParticlesView(isActive: $particlesActive)
|
||||
.opacity(0.2)
|
||||
.drawingGroup()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
startAnimations()
|
||||
}
|
||||
}
|
||||
|
||||
private func startAnimations() {
|
||||
// Glow animation
|
||||
withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) {
|
||||
glowOpacity = 0.3
|
||||
glowScale = 1.2
|
||||
}
|
||||
|
||||
// Start particles
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
particlesActive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Particles
|
||||
struct ParticlesView: View {
|
||||
@Binding var isActive: Bool
|
||||
let particleCount = 60 // Increased particle count
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation) { timeline in
|
||||
Canvas { context, size in
|
||||
let timeOffset = timeline.date.timeIntervalSinceReferenceDate
|
||||
|
||||
for i in 0..<particleCount {
|
||||
let position = particlePosition(index: i, time: timeOffset, size: size)
|
||||
let opacity = particleOpacity(index: i, time: timeOffset)
|
||||
let scale = particleScale(index: i, time: timeOffset)
|
||||
|
||||
context.opacity = opacity
|
||||
context.fill(
|
||||
Circle().path(in: CGRect(
|
||||
x: position.x - scale/2,
|
||||
y: position.y - scale/2,
|
||||
width: scale,
|
||||
height: scale
|
||||
)),
|
||||
with: .color(.white)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(isActive ? 1 : 0)
|
||||
}
|
||||
|
||||
private func particlePosition(index: Int, time: TimeInterval, size: CGSize) -> CGPoint {
|
||||
let relativeIndex = Double(index) / Double(particleCount)
|
||||
let speed = 0.3 // Slower, more graceful movement
|
||||
let radius = min(size.width, size.height) * 0.45
|
||||
|
||||
let angle = time * speed + relativeIndex * .pi * 4
|
||||
let x = sin(angle) * radius + size.width * 0.5
|
||||
let y = cos(angle * 0.5) * radius + size.height * 0.5
|
||||
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
private func particleOpacity(index: Int, time: TimeInterval) -> Double {
|
||||
let relativeIndex = Double(index) / Double(particleCount)
|
||||
return (sin(time + relativeIndex * .pi * 2) + 1) * 0.3 // Reduced opacity for subtlety
|
||||
}
|
||||
|
||||
private func particleScale(index: Int, time: TimeInterval) -> CGFloat {
|
||||
let relativeIndex = Double(index) / Double(particleCount)
|
||||
let baseScale: CGFloat = 3
|
||||
return baseScale + sin(time * 2 + relativeIndex * .pi) * 2
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Button Style
|
||||
struct ScaleButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
OnboardingView(hasCompletedOnboarding: .constant(false))
|
||||
}
|
||||
|
||||
294
VoiceInk/Views/PermissionsView.swift
Normal file
@ -0,0 +1,294 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import Cocoa
|
||||
import KeyboardShortcuts
|
||||
|
||||
class PermissionManager: ObservableObject {
|
||||
@Published var audioPermissionStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
@Published var isAccessibilityEnabled = false
|
||||
@Published var isScreenRecordingEnabled = false
|
||||
@Published var isKeyboardShortcutSet = false
|
||||
|
||||
init() {
|
||||
// Start observing system events that might indicate permission changes
|
||||
setupNotificationObservers()
|
||||
|
||||
// Initial permission checks
|
||||
checkAllPermissions()
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
private func setupNotificationObservers() {
|
||||
// Only observe when app becomes active, as this is a likely time for permissions to have changed
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationDidBecomeActive),
|
||||
name: NSApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func applicationDidBecomeActive() {
|
||||
checkAllPermissions()
|
||||
}
|
||||
|
||||
func checkAllPermissions() {
|
||||
checkAccessibilityPermissions()
|
||||
checkScreenRecordingPermission()
|
||||
checkAudioPermissionStatus()
|
||||
checkKeyboardShortcut()
|
||||
}
|
||||
|
||||
func checkAccessibilityPermissions() {
|
||||
let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false]
|
||||
let accessibilityEnabled = AXIsProcessTrustedWithOptions(options)
|
||||
DispatchQueue.main.async {
|
||||
self.isAccessibilityEnabled = accessibilityEnabled
|
||||
}
|
||||
}
|
||||
|
||||
func checkScreenRecordingPermission() {
|
||||
DispatchQueue.main.async {
|
||||
self.isScreenRecordingEnabled = CGPreflightScreenCaptureAccess()
|
||||
}
|
||||
}
|
||||
|
||||
func requestScreenRecordingPermission() {
|
||||
CGRequestScreenCaptureAccess()
|
||||
}
|
||||
|
||||
func checkAudioPermissionStatus() {
|
||||
DispatchQueue.main.async {
|
||||
self.audioPermissionStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
}
|
||||
}
|
||||
|
||||
func requestAudioPermission() {
|
||||
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
||||
DispatchQueue.main.async {
|
||||
self.audioPermissionStatus = granted ? .authorized : .denied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkKeyboardShortcut() {
|
||||
DispatchQueue.main.async {
|
||||
self.isKeyboardShortcutSet = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PermissionCard: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
let isGranted: Bool
|
||||
let buttonTitle: String
|
||||
let buttonAction: () -> Void
|
||||
let checkPermission: () -> Void
|
||||
@State private var isRefreshing = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(spacing: 16) {
|
||||
// Icon with background
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isGranted ? Color.green.opacity(0.15) : Color.orange.opacity(0.15))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: isGranted ? "\(icon).fill" : icon)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundColor(isGranted ? .green : .orange)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Status indicator with refresh
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {
|
||||
withAnimation(.easeInOut(duration: 0.5)) {
|
||||
isRefreshing = true
|
||||
}
|
||||
checkPermission()
|
||||
|
||||
// Reset the animation after a delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
isRefreshing = false
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
.rotationEffect(.degrees(isRefreshing ? 360 : 0))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
|
||||
if isGranted {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.green)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
} else {
|
||||
Image(systemName: "xmark.seal.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.orange)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isGranted {
|
||||
Button(action: buttonAction) {
|
||||
HStack {
|
||||
Text(buttonTitle)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.right")
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.accentColor, Color.accentColor.opacity(0.8)],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.windowBackgroundColor).opacity(0.9))
|
||||
.cornerRadius(16)
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 5, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
struct PermissionsView: View {
|
||||
@StateObject private var permissionManager = PermissionManager()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 32) {
|
||||
// Header
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "shield.lefthalf.filled")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.blue)
|
||||
.padding(20)
|
||||
.background(Circle()
|
||||
.fill(Color(.windowBackgroundColor).opacity(0.9))
|
||||
.shadow(color: .black.opacity(0.1), radius: 10, y: 5))
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("App Permissions")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
Text("VoiceInk requires the following permissions to function properly")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 40)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Permission Cards
|
||||
VStack(spacing: 16) {
|
||||
// Keyboard Shortcut Permission
|
||||
PermissionCard(
|
||||
icon: "keyboard",
|
||||
title: "Keyboard Shortcut",
|
||||
description: "Set up a keyboard shortcut to use VoiceInk anywhere",
|
||||
isGranted: permissionManager.isKeyboardShortcutSet,
|
||||
buttonTitle: "Configure Shortcut",
|
||||
buttonAction: {
|
||||
NotificationCenter.default.post(
|
||||
name: .navigateToDestination,
|
||||
object: nil,
|
||||
userInfo: ["destination": "Settings"]
|
||||
)
|
||||
},
|
||||
checkPermission: { permissionManager.checkKeyboardShortcut() }
|
||||
)
|
||||
|
||||
// Audio Permission
|
||||
PermissionCard(
|
||||
icon: "mic",
|
||||
title: "Microphone Access",
|
||||
description: "Allow VoiceInk to record your voice for transcription",
|
||||
isGranted: permissionManager.audioPermissionStatus == .authorized,
|
||||
buttonTitle: permissionManager.audioPermissionStatus == .notDetermined ? "Request Permission" : "Open System Settings",
|
||||
buttonAction: {
|
||||
if permissionManager.audioPermissionStatus == .notDetermined {
|
||||
permissionManager.requestAudioPermission()
|
||||
} else {
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
},
|
||||
checkPermission: { permissionManager.checkAudioPermissionStatus() }
|
||||
)
|
||||
|
||||
// Accessibility Permission
|
||||
PermissionCard(
|
||||
icon: "hand.raised",
|
||||
title: "Accessibility Access",
|
||||
description: "Allow VoiceInk to paste transcribed text directly at your cursor position",
|
||||
isGranted: permissionManager.isAccessibilityEnabled,
|
||||
buttonTitle: "Open System Settings",
|
||||
buttonAction: {
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
},
|
||||
checkPermission: { permissionManager.checkAccessibilityPermissions() }
|
||||
)
|
||||
|
||||
// Screen Recording Permission
|
||||
PermissionCard(
|
||||
icon: "rectangle.on.rectangle",
|
||||
title: "Screen Recording Access",
|
||||
description: "Allow VoiceInk to understand context from your screen for transcript Enhancement",
|
||||
isGranted: permissionManager.isScreenRecordingEnabled,
|
||||
buttonTitle: "Request Permission",
|
||||
buttonAction: {
|
||||
permissionManager.requestScreenRecordingPermission()
|
||||
// After requesting, open system preferences as fallback
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
},
|
||||
checkPermission: { permissionManager.checkScreenRecordingPermission() }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.onAppear {
|
||||
permissionManager.checkAllPermissions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PermissionsView()
|
||||
}
|
||||
742
VoiceInk/Views/PowerModeView.swift
Normal file
@ -0,0 +1,742 @@
|
||||
import SwiftUI
|
||||
|
||||
// Configuration Mode Enum
|
||||
enum ConfigurationMode {
|
||||
case add
|
||||
case edit(PowerModeConfig)
|
||||
case editDefault(PowerModeConfig)
|
||||
|
||||
var isAdding: Bool {
|
||||
if case .add = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var isEditingDefault: Bool {
|
||||
if case .editDefault = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .add: return "Add Configuration"
|
||||
case .editDefault: return "Edit Default Configuration"
|
||||
case .edit: return "Edit Configuration"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration Type
|
||||
enum ConfigurationType {
|
||||
case application
|
||||
case website
|
||||
}
|
||||
|
||||
// Main Configuration Sheet
|
||||
struct ConfigurationSheet: View {
|
||||
let mode: ConfigurationMode
|
||||
@Binding var isPresented: Bool
|
||||
let powerModeManager: PowerModeManager
|
||||
@EnvironmentObject var enhancementService: AIEnhancementService
|
||||
|
||||
// State for configuration
|
||||
@State private var configurationType: ConfigurationType = .application
|
||||
@State private var selectedAppURL: URL?
|
||||
@State private var isAIEnhancementEnabled: Bool
|
||||
@State private var selectedPromptId: UUID?
|
||||
@State private var installedApps: [(url: URL, name: String, bundleId: String, icon: NSImage)] = []
|
||||
@State private var searchText = ""
|
||||
|
||||
// Website configuration state
|
||||
@State private var websiteURL: String = ""
|
||||
@State private var websiteName: String = ""
|
||||
|
||||
private var filteredApps: [(url: URL, name: String, bundleId: String, icon: NSImage)] {
|
||||
if searchText.isEmpty {
|
||||
return installedApps
|
||||
}
|
||||
return installedApps.filter { app in
|
||||
app.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
app.bundleId.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
init(mode: ConfigurationMode, isPresented: Binding<Bool>, powerModeManager: PowerModeManager) {
|
||||
self.mode = mode
|
||||
self._isPresented = isPresented
|
||||
self.powerModeManager = powerModeManager
|
||||
|
||||
switch mode {
|
||||
case .add:
|
||||
_isAIEnhancementEnabled = State(initialValue: true)
|
||||
_selectedPromptId = State(initialValue: nil)
|
||||
case .edit(let config), .editDefault(let config):
|
||||
_isAIEnhancementEnabled = State(initialValue: config.isAIEnhancementEnabled)
|
||||
_selectedPromptId = State(initialValue: config.selectedPrompt.flatMap { UUID(uuidString: $0) })
|
||||
if case .edit(let config) = mode {
|
||||
// Initialize website configuration if it exists
|
||||
if let urlConfig = config.urlConfigs?.first {
|
||||
_configurationType = State(initialValue: .website)
|
||||
_websiteURL = State(initialValue: urlConfig.url)
|
||||
_websiteName = State(initialValue: config.appName)
|
||||
} else {
|
||||
_configurationType = State(initialValue: .application)
|
||||
_selectedAppURL = State(initialValue: NSWorkspace.shared.urlForApplication(withBundleIdentifier: config.bundleIdentifier))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text(mode.title)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
|
||||
if mode.isAdding {
|
||||
// Configuration Type Selector
|
||||
Picker("Configuration Type", selection: $configurationType) {
|
||||
Text("Application").tag(ConfigurationType.application)
|
||||
Text("Website").tag(ConfigurationType.website)
|
||||
}
|
||||
|
||||
.padding()
|
||||
|
||||
if configurationType == .application {
|
||||
// Search bar
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.secondary)
|
||||
TextField("Search applications...", text: $searchText)
|
||||
.textFieldStyle(PlainTextFieldStyle())
|
||||
if !searchText.isEmpty {
|
||||
Button(action: { searchText = "" }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color(.windowBackgroundColor).opacity(0.4))
|
||||
.cornerRadius(8)
|
||||
.padding()
|
||||
|
||||
// App Grid
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100, maximum: 120), spacing: 16)], spacing: 16) {
|
||||
ForEach(filteredApps.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }), id: \.bundleId) { app in
|
||||
AppGridItem(
|
||||
app: app,
|
||||
isSelected: app.url == selectedAppURL,
|
||||
action: { selectedAppURL = app.url }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
} else {
|
||||
// Website Configuration
|
||||
VStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Website Name")
|
||||
.font(.headline)
|
||||
TextField("Enter website name", text: $websiteName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Website URL")
|
||||
.font(.headline)
|
||||
TextField("Enter website URL (e.g., google.com)", text: $websiteURL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration Form
|
||||
if let config = getConfigForForm() {
|
||||
if let appURL = !mode.isEditingDefault ? NSWorkspace.shared.urlForApplication(withBundleIdentifier: config.bundleIdentifier) : nil {
|
||||
AppConfigurationFormView(
|
||||
appName: config.appName,
|
||||
appIcon: NSWorkspace.shared.icon(forFile: appURL.path),
|
||||
isDefaultConfig: mode.isEditingDefault,
|
||||
isAIEnhancementEnabled: $isAIEnhancementEnabled,
|
||||
selectedPromptId: $selectedPromptId
|
||||
)
|
||||
} else {
|
||||
AppConfigurationFormView(
|
||||
appName: nil,
|
||||
appIcon: nil,
|
||||
isDefaultConfig: mode.isEditingDefault,
|
||||
isAIEnhancementEnabled: $isAIEnhancementEnabled,
|
||||
selectedPromptId: $selectedPromptId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Bottom buttons
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
}
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(mode.isAdding ? "Add" : "Save") {
|
||||
saveConfiguration()
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
.disabled(mode.isAdding && !canSave)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(width: 600)
|
||||
.frame(maxHeight: mode.isAdding ? 700 : 600)
|
||||
.onAppear {
|
||||
print("🔍 ConfigurationSheet appeared - Mode: \(mode)")
|
||||
if mode.isAdding {
|
||||
print("🔍 Loading installed apps...")
|
||||
loadInstalledApps()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
if configurationType == .application {
|
||||
return selectedAppURL != nil
|
||||
} else {
|
||||
return !websiteURL.isEmpty && !websiteName.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private func getConfigForForm() -> PowerModeConfig? {
|
||||
switch mode {
|
||||
case .add:
|
||||
if configurationType == .application {
|
||||
guard let url = selectedAppURL,
|
||||
let bundle = Bundle(url: url),
|
||||
let bundleId = bundle.bundleIdentifier else { return nil }
|
||||
|
||||
let appName = bundle.infoDictionary?["CFBundleName"] as? String ??
|
||||
bundle.infoDictionary?["CFBundleDisplayName"] as? String ??
|
||||
"Unknown App"
|
||||
|
||||
return PowerModeConfig(
|
||||
bundleIdentifier: bundleId,
|
||||
appName: appName,
|
||||
isAIEnhancementEnabled: isAIEnhancementEnabled,
|
||||
selectedPrompt: selectedPromptId?.uuidString
|
||||
)
|
||||
} else {
|
||||
// Create a special PowerModeConfig for websites
|
||||
let urlConfig = URLConfig(url: websiteURL, promptId: selectedPromptId?.uuidString)
|
||||
return PowerModeConfig(
|
||||
bundleIdentifier: "website.\(UUID().uuidString)",
|
||||
appName: websiteName,
|
||||
isAIEnhancementEnabled: isAIEnhancementEnabled,
|
||||
selectedPrompt: selectedPromptId?.uuidString,
|
||||
urlConfigs: [urlConfig]
|
||||
)
|
||||
}
|
||||
case .edit(let config), .editDefault(let config):
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
private func loadInstalledApps() {
|
||||
// Get both user-installed and system applications
|
||||
let userAppURLs = FileManager.default.urls(for: .applicationDirectory, in: .localDomainMask)
|
||||
let systemAppURLs = FileManager.default.urls(for: .applicationDirectory, in: .systemDomainMask)
|
||||
let allAppURLs = userAppURLs + systemAppURLs
|
||||
|
||||
let apps = allAppURLs.flatMap { url -> [URL] in
|
||||
return (try? FileManager.default.contentsOfDirectory(
|
||||
at: url,
|
||||
includingPropertiesForKeys: [.isApplicationKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)) ?? []
|
||||
}
|
||||
|
||||
installedApps = apps.compactMap { url in
|
||||
guard let bundle = Bundle(url: url),
|
||||
let bundleId = bundle.bundleIdentifier,
|
||||
let name = (bundle.infoDictionary?["CFBundleName"] as? String) ??
|
||||
(bundle.infoDictionary?["CFBundleDisplayName"] as? String) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let icon = NSWorkspace.shared.icon(forFile: url.path)
|
||||
return (url: url, name: name, bundleId: bundleId, icon: icon)
|
||||
}
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
private func saveConfiguration() {
|
||||
if isAIEnhancementEnabled && selectedPromptId == nil {
|
||||
selectedPromptId = enhancementService.allPrompts.first?.id
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case .add:
|
||||
if let config = getConfigForForm() {
|
||||
powerModeManager.addConfiguration(config)
|
||||
}
|
||||
case .edit(let config), .editDefault(let config):
|
||||
var updatedConfig = config
|
||||
updatedConfig.isAIEnhancementEnabled = isAIEnhancementEnabled
|
||||
updatedConfig.selectedPrompt = selectedPromptId?.uuidString
|
||||
|
||||
// Update URL configurations if this is a website config
|
||||
if configurationType == .website {
|
||||
let urlConfig = URLConfig(url: cleanURL(websiteURL), promptId: selectedPromptId?.uuidString)
|
||||
updatedConfig.urlConfigs = [urlConfig]
|
||||
updatedConfig.appName = websiteName
|
||||
}
|
||||
|
||||
powerModeManager.updateConfiguration(updatedConfig)
|
||||
}
|
||||
|
||||
isPresented = false
|
||||
}
|
||||
|
||||
private func cleanURL(_ url: String) -> String {
|
||||
var cleanedURL = url.lowercased()
|
||||
.replacingOccurrences(of: "https://", with: "")
|
||||
.replacingOccurrences(of: "http://", with: "")
|
||||
.replacingOccurrences(of: "www.", with: "")
|
||||
|
||||
// Remove trailing slash if present
|
||||
if cleanedURL.last == "/" {
|
||||
cleanedURL.removeLast()
|
||||
}
|
||||
|
||||
return cleanedURL
|
||||
}
|
||||
}
|
||||
|
||||
// Main View
|
||||
struct PowerModeView: View {
|
||||
@StateObject private var powerModeManager = PowerModeManager.shared
|
||||
@EnvironmentObject private var enhancementService: AIEnhancementService
|
||||
@State private var showingConfigSheet = false {
|
||||
didSet {
|
||||
print("🔍 showingConfigSheet changed to: \(showingConfigSheet)")
|
||||
}
|
||||
}
|
||||
@State private var configurationMode: ConfigurationMode? {
|
||||
didSet {
|
||||
print("🔍 configurationMode changed to: \(String(describing: configurationMode))")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 32) {
|
||||
// Video CTA Section
|
||||
VideoCTAView(
|
||||
url: "https://dub.sh/powermode",
|
||||
subtitle: "See Power Mode in action"
|
||||
)
|
||||
|
||||
// Default Configuration Section
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Default Configuration")
|
||||
.font(.headline)
|
||||
|
||||
ConfiguredAppRow(
|
||||
config: powerModeManager.defaultConfig,
|
||||
isEditing: configurationMode?.isEditingDefault ?? false,
|
||||
action: {
|
||||
configurationMode = .editDefault(powerModeManager.defaultConfig)
|
||||
showingConfigSheet = true
|
||||
}
|
||||
)
|
||||
.background(RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(.windowBackgroundColor).opacity(0.4)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.accentColor.opacity(0.2), lineWidth: 1))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Apps Section
|
||||
VStack(spacing: 16) {
|
||||
if powerModeManager.configurations.isEmpty {
|
||||
PowerModeEmptyStateView(
|
||||
showAddModal: $showingConfigSheet,
|
||||
configMode: $configurationMode
|
||||
)
|
||||
} else {
|
||||
Text("Power Mode Configurations")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
|
||||
ConfiguredAppsGrid(powerModeManager: powerModeManager)
|
||||
|
||||
Button(action: {
|
||||
print("🔍 Add button clicked - Setting config mode and showing sheet")
|
||||
configurationMode = .add
|
||||
print("🔍 Configuration mode set to: \(String(describing: configurationMode))")
|
||||
showingConfigSheet = true
|
||||
print("🔍 showingConfigSheet set to: \(showingConfigSheet)")
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Text("Add New Mode")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.regular)
|
||||
.tint(Color(NSColor.controlAccentColor))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.help("Add a new mode")
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.sheet(isPresented: $showingConfigSheet, onDismiss: {
|
||||
print("🔍 Sheet dismissed - Clearing configuration mode")
|
||||
configurationMode = nil
|
||||
}) {
|
||||
Group {
|
||||
if let mode = configurationMode {
|
||||
ConfigurationSheet(
|
||||
mode: mode,
|
||||
isPresented: $showingConfigSheet,
|
||||
powerModeManager: powerModeManager
|
||||
)
|
||||
.environmentObject(enhancementService)
|
||||
.onAppear {
|
||||
print("🔍 Creating ConfigurationSheet with mode: \(mode)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Supporting Views
|
||||
struct PowerModeEmptyStateView: View {
|
||||
@Binding var showAddModal: Bool
|
||||
@Binding var configMode: ConfigurationMode?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "bolt.circle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("No Applications Configured")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("Add applications to customize their AI enhancement settings.")
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button(action: {
|
||||
print("🔍 Empty state Add Application button clicked")
|
||||
configMode = .add
|
||||
print("🔍 Configuration mode set to: \(String(describing: configMode))")
|
||||
showAddModal = true
|
||||
print("🔍 Empty state showAddModal set to: \(showAddModal)")
|
||||
}) {
|
||||
Label("Add Application", systemImage: "plus.circle.fill")
|
||||
.font(.headline)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfiguredAppsGrid: View {
|
||||
@ObservedObject var powerModeManager: PowerModeManager
|
||||
@EnvironmentObject var enhancementService: AIEnhancementService
|
||||
@State private var editingConfig: PowerModeConfig?
|
||||
@State private var showingConfigSheet = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(powerModeManager.configurations.sorted(by: { $0.appName.localizedCaseInsensitiveCompare($1.appName) == .orderedAscending })) { config in
|
||||
ConfiguredAppRow(
|
||||
config: config,
|
||||
isEditing: editingConfig?.id == config.id,
|
||||
action: {
|
||||
editingConfig = config
|
||||
showingConfigSheet = true
|
||||
}
|
||||
)
|
||||
.contextMenu {
|
||||
Button(action: {
|
||||
editingConfig = config
|
||||
showingConfigSheet = true
|
||||
}) {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
Button(role: .destructive, action: {
|
||||
powerModeManager.removeConfiguration(for: config.bundleIdentifier)
|
||||
}) {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.sheet(isPresented: $showingConfigSheet, onDismiss: { editingConfig = nil }) {
|
||||
if let config = editingConfig {
|
||||
ConfigurationSheet(
|
||||
mode: .edit(config),
|
||||
isPresented: $showingConfigSheet,
|
||||
powerModeManager: powerModeManager
|
||||
)
|
||||
.environmentObject(enhancementService)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfiguredAppRow: View {
|
||||
let config: PowerModeConfig
|
||||
let isEditing: Bool
|
||||
let action: () -> Void
|
||||
@EnvironmentObject var enhancementService: AIEnhancementService
|
||||
|
||||
private var selectedPrompt: CustomPrompt? {
|
||||
guard let promptId = config.selectedPrompt,
|
||||
let uuid = UUID(uuidString: promptId) else { return nil }
|
||||
return enhancementService.allPrompts.first { $0.id == uuid }
|
||||
}
|
||||
|
||||
private var isWebsiteConfig: Bool {
|
||||
return config.urlConfigs != nil && !config.urlConfigs!.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 12) {
|
||||
// Icon
|
||||
if isWebsiteConfig {
|
||||
Image(systemName: "globe")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 32, height: 32)
|
||||
.foregroundColor(.accentColor)
|
||||
} else if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: config.bundleIdentifier) {
|
||||
Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path))
|
||||
.resizable()
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
|
||||
// Info
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(config.appName)
|
||||
.font(.headline)
|
||||
if isWebsiteConfig {
|
||||
if let urlConfig = config.urlConfigs?.first {
|
||||
Text(urlConfig.url)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text(config.bundleIdentifier)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: config.isAIEnhancementEnabled ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(config.isAIEnhancementEnabled ? .accentColor : .secondary)
|
||||
.font(.system(size: 14))
|
||||
Text("AI Enhancement")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(config.isAIEnhancementEnabled ? .accentColor : .secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(RoundedRectangle(cornerRadius: 6)
|
||||
.fill(config.isAIEnhancementEnabled ? Color.accentColor.opacity(0.1) : Color.secondary.opacity(0.1)))
|
||||
|
||||
if config.isAIEnhancementEnabled {
|
||||
if let prompt = selectedPrompt {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: prompt.icon.rawValue)
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.system(size: 14))
|
||||
Text(prompt.title)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.accentColor.opacity(0.1)))
|
||||
} else {
|
||||
Text("No Prompt")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.secondary.opacity(0.1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 8)
|
||||
.fill(isEditing ? Color.accentColor.opacity(0.1) : Color(.windowBackgroundColor).opacity(0.4)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(isEditing ? Color.accentColor : Color.clear, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppConfigurationFormView: View {
|
||||
let appName: String?
|
||||
let appIcon: NSImage?
|
||||
let isDefaultConfig: Bool
|
||||
@Binding var isAIEnhancementEnabled: Bool
|
||||
@Binding var selectedPromptId: UUID?
|
||||
@EnvironmentObject var enhancementService: AIEnhancementService
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
VStack(spacing: 16) {
|
||||
if !isDefaultConfig {
|
||||
if let appIcon = appIcon {
|
||||
HStack {
|
||||
Image(nsImage: appIcon)
|
||||
.resizable()
|
||||
.frame(width: 32, height: 32)
|
||||
Text(appName ?? "")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.accentColor)
|
||||
Text("Default Settings")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text("These settings will be applied to all applications that don't have specific configurations.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
Toggle("AI Enhancement", isOn: $isAIEnhancementEnabled)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if isAIEnhancementEnabled {
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("Select Prompt")
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
|
||||
let columns = [
|
||||
GridItem(.adaptive(minimum: 80, maximum: 100), spacing: 36)
|
||||
]
|
||||
|
||||
LazyVGrid(columns: columns, spacing: 24) {
|
||||
ForEach(enhancementService.allPrompts) { prompt in
|
||||
prompt.promptIcon(
|
||||
isSelected: selectedPromptId == prompt.id,
|
||||
onTap: { selectedPromptId = prompt.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppGridItem: View {
|
||||
let app: (url: URL, name: String, bundleId: String, icon: NSImage)
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 8) {
|
||||
Image(nsImage: app.icon)
|
||||
.resizable()
|
||||
.frame(width: 48, height: 48)
|
||||
Text(app.name)
|
||||
.font(.system(size: 12))
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(height: 32)
|
||||
}
|
||||
.frame(width: 100)
|
||||
.padding(8)
|
||||
.background(RoundedRectangle(cornerRadius: 8)
|
||||
.fill(isSelected ? Color.accentColor.opacity(0.1) : Color.clear))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// New component for feature highlights
|
||||
struct FeatureHighlight: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
|
||||
Text(description)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
299
VoiceInk/Views/PromptEditorView.swift
Normal file
@ -0,0 +1,299 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PromptEditorView: View {
|
||||
enum Mode {
|
||||
case add
|
||||
case edit(CustomPrompt)
|
||||
|
||||
static func == (lhs: Mode, rhs: Mode) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.add, .add):
|
||||
return true
|
||||
case let (.edit(prompt1), .edit(prompt2)):
|
||||
return prompt1.id == prompt2.id
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var enhancementService: AIEnhancementService
|
||||
@State private var title: String
|
||||
@State private var promptText: String
|
||||
@State private var selectedIcon: PromptIcon
|
||||
@State private var description: String
|
||||
@State private var showingPredefinedPrompts = false
|
||||
|
||||
init(mode: Mode) {
|
||||
self.mode = mode
|
||||
switch mode {
|
||||
case .add:
|
||||
_title = State(initialValue: "")
|
||||
_promptText = State(initialValue: "")
|
||||
_selectedIcon = State(initialValue: .documentFill)
|
||||
_description = State(initialValue: "")
|
||||
case .edit(let prompt):
|
||||
_title = State(initialValue: prompt.title)
|
||||
_promptText = State(initialValue: prompt.promptText)
|
||||
_selectedIcon = State(initialValue: prompt.icon)
|
||||
_description = State(initialValue: prompt.description ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header with modern styling
|
||||
HStack {
|
||||
Text(mode == .add ? "New Mode" : "Edit Mode")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
Spacer()
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button {
|
||||
save()
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Save")
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(title.isEmpty || promptText.isEmpty)
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
Color(NSColor.windowBackgroundColor)
|
||||
.shadow(color: .black.opacity(0.1), radius: 8, y: 2)
|
||||
)
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Title and Icon Section with improved layout
|
||||
HStack(spacing: 20) {
|
||||
// Title Field
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Title")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
TextField("Enter a short, descriptive title", text: $title)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.body)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Icon Selector with preview
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Icon")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Menu {
|
||||
IconMenuContent(selectedIcon: $selectedIcon)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: selectedIcon.rawValue)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: 24)
|
||||
|
||||
Text(selectedIcon.title)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.frame(width: 180)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
|
||||
// Description Field
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Description")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Add a brief description of what this mode does")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("Enter a description", text: $description)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.body)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Prompt Text Section with improved styling
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Mode Instructions")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Define how AI should enhance your transcriptions")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextEditor(text: $promptText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 200)
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(NSColor.textBackgroundColor))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if case .add = mode {
|
||||
// Templates Section with improved styling
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Templates")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Start with a predefined template")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(PromptTemplates.all) { template in
|
||||
TemplateButton(prompt: template) {
|
||||
title = template.title
|
||||
promptText = template.promptText
|
||||
selectedIcon = template.icon
|
||||
description = template.description
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
.scrollClipDisabled(true)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
switch mode {
|
||||
case .add:
|
||||
enhancementService.addPrompt(
|
||||
title: title,
|
||||
promptText: promptText,
|
||||
icon: selectedIcon,
|
||||
description: description.isEmpty ? nil : description
|
||||
)
|
||||
case .edit(let prompt):
|
||||
let updatedPrompt = CustomPrompt(
|
||||
id: prompt.id,
|
||||
title: title,
|
||||
promptText: promptText,
|
||||
isActive: prompt.isActive,
|
||||
icon: selectedIcon,
|
||||
description: description.isEmpty ? nil : description
|
||||
)
|
||||
enhancementService.updatePrompt(updatedPrompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Template button with modern styling
|
||||
struct TemplateButton: View {
|
||||
let prompt: TemplatePrompt
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: prompt.icon.rawValue)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.accentColor)
|
||||
Text(prompt.title)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
|
||||
Text(prompt.description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.frame(width: 200, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(10)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// Icon menu content for better organization
|
||||
struct IconMenuContent: View {
|
||||
@Binding var selectedIcon: PromptIcon
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
IconMenuSection(title: "Document & Text", icons: [.documentFill, .textbox, .sealedFill], selectedIcon: $selectedIcon)
|
||||
IconMenuSection(title: "Communication", icons: [.chatFill, .messageFill, .emailFill], selectedIcon: $selectedIcon)
|
||||
IconMenuSection(title: "Professional", icons: [.meetingFill, .presentationFill, .briefcaseFill], selectedIcon: $selectedIcon)
|
||||
IconMenuSection(title: "Technical", icons: [.codeFill, .terminalFill, .gearFill], selectedIcon: $selectedIcon)
|
||||
IconMenuSection(title: "Content", icons: [.blogFill, .notesFill, .bookFill, .bookmarkFill, .pencilFill], selectedIcon: $selectedIcon)
|
||||
IconMenuSection(title: "Media & Creative", icons: [.videoFill, .micFill, .musicFill, .photoFill, .brushFill], selectedIcon: $selectedIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Icon menu section for better organization
|
||||
struct IconMenuSection: View {
|
||||
let title: String
|
||||
let icons: [PromptIcon]
|
||||
@Binding var selectedIcon: PromptIcon
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
ForEach(icons, id: \.self) { icon in
|
||||
Button(action: { selectedIcon = icon }) {
|
||||
Label(icon.title, systemImage: icon.rawValue)
|
||||
}
|
||||
}
|
||||
if title != "Media & Creative" {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||