Initial commit: Open-sourcing VoiceInk

This commit is contained in:
Beingpax 2025-02-22 11:52:41 +05:45
commit 76a154706c
113 changed files with 16592 additions and 0 deletions

100
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

View 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 */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -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
}

View 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)
}
}
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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"}]}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "Frame 1.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

236
VoiceInk/AudioEngine.swift Normal file
View 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()
}
}

View 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))
}
}

View 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)
}
}
}
}

View 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)")
}
}
}

View 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
View 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
View 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
}

View 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")
}

View 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
}
}

View 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)
}
}
}
}

View 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"
)
]
}

View 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
)
]
}
}

View 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"
)
]
}
}

View 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
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

174
VoiceInk/Recorder.swift Normal file
View 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)
}
}
}

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,7 @@
tell application "Arc"
tell front window
tell active tab
return URL
end tell
end tell
end tell

View File

@ -0,0 +1,5 @@
tell application "Brave Browser"
tell active tab of front window
return URL
end tell
end tell

View File

@ -0,0 +1,5 @@
tell application "Google Chrome"
tell active tab of front window
return URL
end tell
end tell

View File

@ -0,0 +1,5 @@
tell application "Microsoft Edge"
tell active tab of front window
return URL
end tell
end tell

View 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

View File

@ -0,0 +1,5 @@
tell application "Opera"
tell active tab of front window
return URL
end tell
end tell

View File

@ -0,0 +1,7 @@
tell application "Orion"
tell front window
tell active tab
return URL
end tell
end tell
end tell

View File

@ -0,0 +1,7 @@
tell application "Safari"
tell front window
tell current tab
return URL
end tell
end tell
end tell

View File

@ -0,0 +1,5 @@
tell application "Vivaldi"
tell active tab of front window
return URL
end tell
end tell

View 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

View 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
}

View 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
}

View 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
"""
}

View 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")
}

View 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
}
}
}
}
}
}

View 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"
}
}
}

View 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)
}
}

View 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 }
}
}

View 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"
}
}
}

View 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"
}
}

View 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
}
}

View 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) }
}
}

View 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 }
}
}

View 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")
}

View 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())
}

View 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()
}
}

View 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)
}
}

View 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()
}

View 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)
}
}
}

View 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)
}
}

View 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()
}
}
}

View 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)
}
}

View 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)
}
}

View 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))
}
}

View 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))
}
}
}

View 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()
}

View 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)
}
}

View 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)
}
}
}

View 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()
}
}

View 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)
}
}

View 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)
}
}
}
}

View 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)
}
}

View 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()
}
}

View 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"]
)
}
}

View 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))
)
}
}

View 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()
}
}

View 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()
}
}

View 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

View 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()
}
}
}

View 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)
}
}

View 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)
])
}
}

View 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
}
}
}
}

View 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
}
}

View 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()
}
}
}

View 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)
}
}
}

View 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"
}
}

View 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
}
}
}

View 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))
}

View 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()
}

View 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)
}
}

View 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()
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More