├── Windows_and_Linux ├── ui │ ├── __init__.py │ ├── UIUtils.py │ ├── AutostartManager.py │ └── OnboardingWindow.py ├── Latest_Version_for_Update_Check.txt ├── background.png ├── background_dark.png ├── icons │ ├── app_icon.ico │ ├── app_icon.png │ ├── copy_dark.png │ ├── list_dark.png │ ├── plus_dark.png │ ├── send_dark.png │ ├── check_dark.png │ ├── check_light.png │ ├── concise_dark.png │ ├── copy_light.png │ ├── cross_dark.png │ ├── cross_light.png │ ├── custom_dark.png │ ├── custom_light.png │ ├── list_light.png │ ├── minus_dark.png │ ├── minus_light.png │ ├── pencil_dark.png │ ├── pencil_light.png │ ├── plus_light.png │ ├── reset_dark.png │ ├── reset_light.png │ ├── restore_dark.png │ ├── rewrite_dark.png │ ├── send_light.png │ ├── summary_dark.png │ ├── table_dark.png │ ├── table_light.png │ ├── briefcase_dark.png │ ├── concise_light.png │ ├── keypoints_dark.png │ ├── restore_light.png │ ├── rewrite_light.png │ ├── summary_light.png │ ├── briefcase_light.png │ ├── keypoints_light.png │ ├── provider_gemini.png │ ├── provider_ollama.png │ ├── provider_openai.png │ ├── regenerate_dark.png │ ├── regenerate_light.png │ ├── rotate-left_dark.png │ ├── rotate-left_light.png │ ├── smiley-face_dark.png │ ├── smiley-face_light.png │ ├── magnifying-glass_dark.png │ └── magnifying-glass_light.png ├── background_popup.png ├── background_popup_dark.png ├── locales │ ├── en │ │ └── LC_MESSAGES │ │ │ └── messages.mo │ └── it │ │ └── LC_MESSAGES │ │ └── messages.mo ├── requirements.txt ├── main.py ├── pot_files │ ├── WritingToolApp.pot │ ├── ResponseWindow.pot │ ├── CustomPopupWindow.pot │ ├── SettingsWindow.pot │ ├── OnboardingWindow.pot │ └── AboutWindow.pot ├── create_translation.sh ├── update_checker.py ├── pyinstaller-build-script.py ├── options.json └── options_examples.json ├── macOS ├── Latest_Version_for_Update_Check.txt ├── WritingTools │ ├── Assets │ │ └── Assets.xcassets │ │ │ ├── Contents.json │ │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ │ └── AppIcon.appiconset │ │ │ ├── WritingTools-macOS-Default-16x16@1x.png │ │ │ ├── WritingTools-macOS-Default-16x16@2x.png │ │ │ ├── WritingTools-macOS-Default-32x32@1x.png │ │ │ ├── WritingTools-macOS-Default-32x32@2x.png │ │ │ ├── WritingTools-macOS-Default-128x128@1x.png │ │ │ ├── WritingTools-macOS-Default-128x128@2x.png │ │ │ ├── WritingTools-macOS-Default-256x256@1x.png │ │ │ ├── WritingTools-macOS-Default-256x256@2x.png │ │ │ ├── WritingTools-macOS-Default-512x512@1x.png │ │ │ ├── WritingTools-macOS-Default-1024x1024@1x.png │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── WritingTools.icon │ │ ├── Assets │ │ │ ├── ChatGPT Image Jul 20, 2025 at 03_23_58 PM.png │ │ │ └── ChatGPT Image Jul 20, 2025 at 03_29_57 PM.png │ │ └── icon.json │ ├── Views │ │ ├── ContentView.swift │ │ ├── Onboarding │ │ │ ├── OnboardingStep.swift │ │ │ └── Views │ │ │ │ ├── ProviderSettings │ │ │ │ ├── MistralProviderSettingsView.swift │ │ │ │ ├── ProviderSettingsContainerView.swift │ │ │ │ ├── OpenAIProviderSettingsView.swift │ │ │ │ ├── GeminiProviderSettingsView.swift │ │ │ │ ├── OpenRouterProviderSettingsView.swift │ │ │ │ ├── AnthropicProviderSettingsView.swift │ │ │ │ └── OllamaProviderSettingsView.swift │ │ │ │ ├── OnboardingWelcomeStep.swift │ │ │ │ ├── PermissionRow.swift │ │ │ │ ├── OnboardingFinishStep.swift │ │ │ │ ├── OnboardingCustomizationStep.swift │ │ │ │ └── OnboardingPermissionsStep.swift │ │ ├── Settings │ │ │ ├── Components │ │ │ │ ├── InfoRow.swift │ │ │ │ └── LinkText.swift │ │ │ ├── Panes │ │ │ │ ├── AppearanceSettingsPane.swift │ │ │ │ ├── AIProviderSettingsPane.swift │ │ │ │ └── GeneralSettingsPane.swift │ │ │ └── Providers │ │ │ │ ├── OpenRouterSettingsView.swift │ │ │ │ ├── MistralSettingsView.swift │ │ │ │ ├── OpenAISettingsView.swift │ │ │ │ ├── GeminiSettingsView.swift │ │ │ │ ├── AnthropicSettingsView.swift │ │ │ │ └── OllamaSettingsView.swift │ │ ├── Chat │ │ │ └── ResponseWindow.swift │ │ ├── Commands │ │ │ └── CommandButton.swift │ │ └── About │ │ │ └── AboutView.swift │ ├── Utilities │ │ └── Custom Modifiers │ │ │ ├── ChatBubble.swift │ │ │ ├── ChatBubbleModifier.swift │ │ │ ├── LoadingModifier.swift │ │ │ └── AppleStyleTextFieldModifier.swift │ ├── Models │ │ ├── AI Providers │ │ │ ├── AIProvider.swift │ │ │ ├── ModelConfiguration.swift │ │ │ ├── LocalModelInfo.swift │ │ │ ├── AnthropicProvider.swift │ │ │ ├── OpenRouterProvider.swift │ │ │ ├── GeminiProvider.swift │ │ │ └── MistralProvider.swift │ │ └── CustomCommand.swift │ ├── writing_tools.entitlements │ ├── Services │ │ ├── ClipboardWait.swift │ │ ├── MigrationHelper.swift │ │ ├── PasteboardRichText.swift │ │ ├── OCRManager.swift │ │ ├── UpdateChecker.swift │ │ ├── ClipboardSnapshot.swift │ │ ├── CloudCommandsSync.swift │ │ └── CommandManager.swift │ ├── writing_toolsApp.swift │ ├── Info.plist │ └── App │ │ ├── KeychainMigrationManager.swift │ │ └── KeychainManager.swift ├── WritingTools.xcodeproj │ └── project.xcworkspace │ │ └── contents.xcworkspacedata ├── .gitignore └── README.md ├── README's Linked Content ├── Demo.mp4 ├── Demo - Summaries.mp4 ├── To Run Writing Tools Directly from the Source Code.md └── To Compile the Application Yourself.md ├── .gitignore └── .github └── FUNDING.yml /Windows_and_Linux/ui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /macOS/Latest_Version_for_Update_Check.txt: -------------------------------------------------------------------------------- 1 | 5.5 -------------------------------------------------------------------------------- /Windows_and_Linux/Latest_Version_for_Update_Check.txt: -------------------------------------------------------------------------------- 1 | 8 2 | -------------------------------------------------------------------------------- /README's Linked Content/Demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/README's Linked Content/Demo.mp4 -------------------------------------------------------------------------------- /Windows_and_Linux/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/background.png -------------------------------------------------------------------------------- /Windows_and_Linux/background_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/background_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/app_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/app_icon.ico -------------------------------------------------------------------------------- /Windows_and_Linux/icons/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/app_icon.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/copy_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/copy_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/list_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/list_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/plus_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/plus_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/send_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/send_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/background_popup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/background_popup.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/check_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/check_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/check_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/check_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/concise_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/concise_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/copy_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/copy_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/cross_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/cross_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/cross_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/cross_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/custom_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/custom_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/custom_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/custom_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/list_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/list_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/minus_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/minus_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/minus_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/minus_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/pencil_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/pencil_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/pencil_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/pencil_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/plus_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/plus_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/reset_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/reset_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/reset_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/reset_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/restore_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/restore_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/rewrite_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/rewrite_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/send_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/send_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/summary_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/summary_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/table_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/table_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/table_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/table_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/briefcase_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/briefcase_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/concise_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/concise_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/keypoints_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/keypoints_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/restore_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/restore_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/rewrite_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/rewrite_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/summary_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/summary_light.png -------------------------------------------------------------------------------- /macOS/WritingTools/Assets/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS system files 2 | 3 | .DS_Store 4 | Windows_and_Linux/config.json 5 | 6 | .idea 7 | **/__pycache__ 8 | **/*.mo 9 | **/pot_files -------------------------------------------------------------------------------- /README's Linked Content/Demo - Summaries.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/README's Linked Content/Demo - Summaries.mp4 -------------------------------------------------------------------------------- /Windows_and_Linux/background_popup_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/background_popup_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/briefcase_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/briefcase_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/keypoints_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/keypoints_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/provider_gemini.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/provider_gemini.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/provider_ollama.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/provider_ollama.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/provider_openai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/provider_openai.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/regenerate_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/regenerate_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/regenerate_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/regenerate_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/rotate-left_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/rotate-left_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/rotate-left_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/rotate-left_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/smiley-face_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/smiley-face_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/smiley-face_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/smiley-face_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/magnifying-glass_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/magnifying-glass_dark.png -------------------------------------------------------------------------------- /Windows_and_Linux/icons/magnifying-glass_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/icons/magnifying-glass_light.png -------------------------------------------------------------------------------- /Windows_and_Linux/locales/en/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/locales/en/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /Windows_and_Linux/locales/it/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/Windows_and_Linux/locales/it/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /macOS/WritingTools/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Windows_and_Linux/requirements.txt: -------------------------------------------------------------------------------- 1 | darkdetect 2 | google-generativeai 3 | openai 4 | pyperclip 5 | pynput 6 | PySide6 7 | markdown2 8 | pyinstaller 9 | ollama 10 | 11 | -------------------------------------------------------------------------------- /macOS/WritingTools.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /macOS/WritingTools/WritingTools.icon/Assets/ChatGPT Image Jul 20, 2025 at 03_23_58 PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/macOS/WritingTools/WritingTools.icon/Assets/ChatGPT Image Jul 20, 2025 at 03_23_58 PM.png -------------------------------------------------------------------------------- /macOS/WritingTools/WritingTools.icon/Assets/ChatGPT Image Jul 20, 2025 at 03_29_57 PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/macOS/WritingTools/WritingTools.icon/Assets/ChatGPT Image Jul 20, 2025 at 03_29_57 PM.png -------------------------------------------------------------------------------- /macOS/WritingTools/Assets/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-16x16@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-16x16@1x.png -------------------------------------------------------------------------------- /macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-16x16@2x.png -------------------------------------------------------------------------------- /macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-32x32@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-32x32@1x.png -------------------------------------------------------------------------------- /macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-32x32@2x.png -------------------------------------------------------------------------------- /macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-128x128@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-128x128@1x.png -------------------------------------------------------------------------------- /macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-128x128@2x.png -------------------------------------------------------------------------------- /macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-256x256@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-256x256@1x.png -------------------------------------------------------------------------------- /macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-256x256@2x.png -------------------------------------------------------------------------------- /macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-512x512@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-512x512@1x.png -------------------------------------------------------------------------------- /macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theJayTea/WritingTools/HEAD/macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/WritingTools-macOS-Default-1024x1024@1x.png -------------------------------------------------------------------------------- /macOS/WritingTools/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ContentView: View { 4 | @ObservedObject var appState: AppState 5 | 6 | var body: some View { 7 | EmptyView() 8 | .frame(width: 0, height: 0) 9 | } 10 | } 11 | 12 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # Writing Tools (the Windows & Linux version) is developed and maintained by Jesai Tarun with the help of awesome contributors. 2 | # If you've found value in Writing Tools, your support would mean the world and would help me continue active development. Thank you! 3 | 4 | buy_me_a_coffee: jesaitarun 5 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Onboarding/OnboardingStep.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingStep.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import Foundation 9 | 10 | struct OnboardingStep { 11 | let title: String 12 | let description: String 13 | let isPermissionStep: Bool 14 | } 15 | -------------------------------------------------------------------------------- /macOS/WritingTools/Utilities/Custom Modifiers/ChatBubble.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ChatBubble: Shape { 4 | var isFromUser: Bool 5 | 6 | func path(in rect: CGRect) -> Path { 7 | // A simple rounded rect with a corner radius 8 | return RoundedRectangle(cornerRadius: 12, style: .continuous) 9 | .path(in: rect) } 10 | } 11 | -------------------------------------------------------------------------------- /Windows_and_Linux/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from WritingToolApp import WritingToolApp 5 | 6 | # Set up logging to console 7 | logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') 8 | 9 | 10 | def main(): 11 | """ 12 | The main entry point of the application. 13 | """ 14 | app = WritingToolApp(sys.argv) 15 | app.setQuitOnLastWindowClosed(False) 16 | sys.exit(app.exec()) 17 | 18 | 19 | if __name__ == '__main__': 20 | main() 21 | -------------------------------------------------------------------------------- /macOS/WritingTools/Models/AI Providers/AIProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @MainActor 4 | protocol AIProvider: ObservableObject { 5 | 6 | // Indicates if provider is processing a request 7 | var isProcessing: Bool { get set } 8 | 9 | // Process text with optional system prompt and images 10 | func processText(systemPrompt: String?, userPrompt: String, images: [Data], streaming: Bool) async throws -> String 11 | 12 | // Cancel ongoing requests 13 | func cancel() 14 | } 15 | -------------------------------------------------------------------------------- /macOS/WritingTools/writing_tools.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.icloud-container-identifiers 6 | 7 | com.apple.developer.ubiquity-kvstore-identifier 8 | $(TeamIdentifierPrefix)$(CFBundleIdentifier) 9 | com.apple.security.files.bookmarks.app-scope 10 | 11 | com.apple.security.temporary-exception.apple-events 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /macOS/WritingTools/Services/ClipboardWait.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClipboardWait.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 08.08.25. 6 | // 7 | 8 | import AppKit 9 | 10 | func waitForPasteboardUpdate( 11 | _ pb: NSPasteboard, 12 | initialChangeCount: Int, 13 | timeout: TimeInterval = 0.6 14 | ) async { 15 | let start = Date() 16 | while pb.changeCount == initialChangeCount && Date().timeIntervalSince(start) < 17 | timeout 18 | { 19 | do { 20 | try await Task.sleep(nanoseconds: 20_000_000) 21 | } catch { 22 | NSLog("Task sleep interrupted: \(error)") 23 | } // 20 ms 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /macOS/WritingTools/Utilities/Custom Modifiers/ChatBubbleModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ChatBubbleModifier: ViewModifier { 4 | let isFromUser: Bool 5 | 6 | func body(content: Content) -> some View { 7 | content 8 | .padding() 9 | .background( 10 | ChatBubble(isFromUser: isFromUser) 11 | .fill(isFromUser ? Color.blue.opacity(0.15) : Color(.controlBackgroundColor)) 12 | ) 13 | } 14 | } 15 | 16 | extension View { 17 | func chatBubbleStyle(isFromUser: Bool) -> some View { 18 | self.modifier(ChatBubbleModifier(isFromUser: isFromUser)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /macOS/WritingTools/writing_toolsApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct writing_toolsApp: App { 5 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 6 | @StateObject private var appState = AppState.shared 7 | 8 | var body: some Scene { 9 | WindowGroup { 10 | ContentView(appState: appState) 11 | .frame(width: 0, height: 0, alignment: .center) 12 | .hidden() 13 | } 14 | .windowStyle(.hiddenTitleBar) 15 | .windowResizability(.contentSize) 16 | .commands { 17 | SidebarCommands() 18 | ToolbarCommands() 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Settings/Components/InfoRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfoRow.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct InfoRow: View { 11 | let label: String 12 | let value: String 13 | 14 | var body: some View { 15 | HStack { 16 | Text(label) 17 | .foregroundColor(.secondary) 18 | Spacer() 19 | Text(value) 20 | } 21 | } 22 | } 23 | 24 | #Preview("InfoRow") { 25 | VStack(alignment: .leading) { 26 | InfoRow(label: "Version", value: "1.0") 27 | InfoRow(label: "Build", value: "100") 28 | } 29 | .padding() 30 | } 31 | -------------------------------------------------------------------------------- /Windows_and_Linux/pot_files/WritingToolApp.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-01-28 19:47+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=CHARSET\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: WritingToolApp.py:569 21 | msgid "Settings" 22 | msgstr "" 23 | 24 | #: WritingToolApp.py:572 25 | msgid "About" 26 | msgstr "" 27 | 28 | #: WritingToolApp.py:575 29 | msgid "Exit" 30 | msgstr "" 31 | -------------------------------------------------------------------------------- /Windows_and_Linux/create_translation.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | set -euo pipefail 3 | 4 | # We generate .pot files from all python scripts... 5 | xgettext --language=Python --keyword=_ WritingToolApp.py -o pot_files/WritingToolApp.pot 6 | for file in ui/*.py; do 7 | output="${file#ui/}" 8 | output="${output%.py}.pot" 9 | xgettext --language=Python --keyword=_ "$file" -o "pot_files/$output" 10 | done 11 | 12 | # ... merge them into a single .pot file... 13 | msgcat pot_files/*.pot -o pot_files/merged.pot 14 | 15 | # ... and update the .po files with the new strings. 16 | for locale in locales/*; do 17 | echo -n "Updating $locale translation files" 18 | msgmerge --update "$locale/LC_MESSAGES/messages.po" pot_files/merged.pot 19 | echo -n "Compiling $locale translation files............" 20 | msgfmt -o "$locale/LC_MESSAGES/messages.mo" "$locale/LC_MESSAGES/messages.po" 21 | echo " done." 22 | done 23 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Settings/Components/LinkText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkText.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | import AppKit 10 | 11 | struct LinkText: View { 12 | var body: some View { 13 | HStack(spacing: 4) { 14 | Text("Local LLMs: use the instructions on") 15 | .font(.caption) 16 | .foregroundColor(.secondary) 17 | Text("GitHub Page.") 18 | .font(.caption) 19 | .foregroundColor(.blue) 20 | .underline() 21 | .onTapGesture { 22 | if let url = URL(string: "https://github.com/theJayTea/WritingTools?tab=readme-ov-file#-optional-ollama-local-llm-instructions") { 23 | NSWorkspace.shared.open(url) 24 | } 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /macOS/.gitignore: -------------------------------------------------------------------------------- 1 | # macOS system files 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | # Build and Derived Data 7 | build/ 8 | DerivedData/ 9 | 10 | # Xcode files 11 | *.xcworkspace 12 | *.xcuserdata 13 | *.xcuserstate 14 | *.xcarchive 15 | *.log 16 | 17 | ### Xcode Patch ### 18 | *.xcodeproj/* 19 | !*.xcodeproj/project.pbxproj 20 | !*.xcodeproj/xcshareddata/ 21 | !*.xcodeproj/project.xcworkspace/ 22 | !*.xcworkspace/contents.xcworkspacedata 23 | /*.gcno 24 | **/xcshareddata/WorkspaceSettings.xcsettings 25 | 26 | # CocoaPods 27 | Pods/ 28 | Podfile.lock 29 | 30 | # Carthage 31 | Carthage/Build/ 32 | 33 | # Swift Package Manager 34 | .swiftpm 35 | Package.resolved 36 | .build/ 37 | 38 | # Fastlane 39 | fastlane/report.xml 40 | fastlane/Preview.html 41 | fastlane/screenshots 42 | fastlane/test_output 43 | .aidigestignore 44 | codebase.md 45 | 46 | # Archives 47 | *.xcarchive 48 | 49 | # Secrets 50 | *.keychain 51 | *.mobileprovision 52 | 53 | # Temporary files 54 | *.swp 55 | *.lock -------------------------------------------------------------------------------- /README's Linked Content/To Run Writing Tools Directly from the Source Code.md: -------------------------------------------------------------------------------- 1 | # 👨‍💻 To Run Writing Tools Directly from the Source Code 2 | 3 | If you prefer to run the program directly from the `main.py` file, follow these OS-specific instructions. 4 | 5 | **1. Download the Code** 6 | - Click the green `<> Code ▼` button toward the very top of this page, and click `Download ZIP`. 7 | 8 | **2. Install Dependencies** 9 | After extracting the folder, open your **Terminal** (or **Command Prompt**) in the relevant directory. 10 | 11 | - Windows: 12 | ```bash 13 | cd path\to\Windows_and_Linux 14 | pip install -r requirements.txt 15 | ``` 16 | 17 | - Linux: 18 | ```bash 19 | cd /path/to/Windows_and_Linux 20 | pip3 install -r requirements.txt 21 | ``` 22 | Of course, you'll need to have [Python installed](https://www.python.org/downloads/)! 23 | 24 | **3. Run the Program** 25 | - **Windows:** 26 | ```bash 27 | python main.py 28 | ``` 29 | - **Linux:** 30 | ```bash 31 | python3 main.py 32 | ``` 33 | 34 | ### [**◀️ Back to main page**](https://github.com/theJayTea/WritingTools) -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Onboarding/Views/ProviderSettings/MistralProviderSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MistralProviderSettingsView.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MistralProviderSettingsView: View { 11 | @ObservedObject var settings: AppSettings 12 | 13 | var body: some View { 14 | VStack(alignment: .leading, spacing: 12) { 15 | Text("Configure Mistral AI") 16 | .font(.headline) 17 | TextField("API Key", text: $settings.mistralApiKey) 18 | .textFieldStyle(.roundedBorder) 19 | 20 | Picker("Model", selection: $settings.mistralModel) { 21 | ForEach(MistralModel.allCases, id: \.self) { model in 22 | Text(model.displayName).tag(model.rawValue) 23 | } 24 | } 25 | .pickerStyle(.menu) 26 | .frame(maxWidth: .infinity, alignment: .leading) 27 | 28 | Button("Get Mistral API Key") { 29 | if let url = URL(string: "https://console.mistral.ai/api-keys/") { 30 | NSWorkspace.shared.open(url) 31 | } 32 | } 33 | .buttonStyle(.link) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Onboarding/Views/ProviderSettings/ProviderSettingsContainerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProviderSettingsContainerView.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ProviderSettingsContainerView: View { 11 | @ObservedObject var settings: AppSettings 12 | @ObservedObject var appState: AppState 13 | 14 | @ViewBuilder 15 | var body: some View { 16 | switch settings.currentProvider { 17 | case "gemini": 18 | GeminiProviderSettingsView(settings: settings) 19 | case "mistral": 20 | MistralProviderSettingsView(settings: settings) 21 | case "anthropic": 22 | AnthropicProviderSettingsView(settings: settings) 23 | case "openai": 24 | OpenAIProviderSettingsView(settings: settings) 25 | case "ollama": 26 | OllamaProviderSettingsView(settings: settings) 27 | case "openrouter": 28 | OpenRouterProviderSettingsView(settings: settings) 29 | case "local": 30 | LocalLLMSettingsView(provider: appState.localLLMProvider) 31 | default: 32 | Text("Select a provider.") 33 | .foregroundColor(.secondary) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Windows_and_Linux/pot_files/ResponseWindow.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-01-28 19:47+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=CHARSET\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: ui/ResponseWindow.py:303 21 | msgid "Response" 22 | msgstr "" 23 | 24 | #: ui/ResponseWindow.py:385 25 | msgid "Select to copy with formatting" 26 | msgstr "" 27 | 28 | #: ui/ResponseWindow.py:390 29 | msgid "Copy as Markdown" 30 | msgstr "" 31 | 32 | #: ui/ResponseWindow.py:401 ui/ResponseWindow.py:513 ui/ResponseWindow.py:515 33 | #: ui/ResponseWindow.py:522 ui/ResponseWindow.py:526 34 | msgid "Thinking" 35 | msgstr "" 36 | 37 | #: ui/ResponseWindow.py:435 ui/ResponseWindow.py:536 38 | msgid "Ask a follow-up question" 39 | msgstr "" 40 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Chat/ResponseWindow.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class ResponseWindow: NSWindow { 4 | private var hostingController: NSHostingController? 5 | 6 | init( 7 | title: String, 8 | content: String, 9 | selectedText: String, 10 | option: WritingOption? = nil, 11 | provider: any AIProvider 12 | ) { 13 | let controller = NSHostingController( 14 | rootView: ResponseView( 15 | content: content, 16 | selectedText: selectedText, 17 | option: option, 18 | provider: provider 19 | ) 20 | ) 21 | self.hostingController = controller 22 | 23 | super.init( 24 | contentRect: NSRect(x: 0, y: 0, width: 600, height: 500), 25 | styleMask: [.titled, .closable, .resizable, .miniaturizable], 26 | backing: .buffered, 27 | defer: false 28 | ) 29 | 30 | self.title = title 31 | self.minSize = NSSize(width: 400, height: 300) 32 | self.isReleasedWhenClosed = false 33 | 34 | self.contentViewController = controller 35 | self.center() 36 | self.setFrameAutosaveName("ResponseWindow") 37 | } 38 | 39 | override func close() { 40 | WindowManager.shared.removeResponseWindow(self) 41 | super.close() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /macOS/WritingTools/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleIdentifier 6 | com.aryamirsepasi.writing-tools 7 | CFBundleName 8 | WritingTools 9 | CFBundleDisplayName 10 | WritingTools 11 | CFBundleExecutable 12 | $(EXECUTABLE_NAME) 13 | CFBundlePackageType 14 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 15 | CFBundleShortVersionString 16 | $(MARKETING_VERSION) 17 | CFBundleVersion 18 | $(CURRENT_PROJECT_VERSION) 19 | LSMinimumSystemVersion 20 | $(MACOSX_DEPLOYMENT_TARGET) 21 | LSUIElement 22 | 23 | NSAccessibilityUsageDescription 24 | WritingTools needs access to control your computer to simulate copy and paste 25 | actions, allowing it to retrieve selected text and insert AI-generated results. 26 | 27 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Onboarding/Views/ProviderSettings/OpenAIProviderSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenAIProviderSettingsView.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct OpenAIProviderSettingsView: View { 11 | @ObservedObject var settings: AppSettings 12 | 13 | var body: some View { 14 | VStack(alignment: .leading, spacing: 12) { 15 | Text("Configure OpenAI (ChatGPT)") 16 | .font(.headline) 17 | TextField("API Key", text: $settings.openAIApiKey) 18 | .textFieldStyle(.roundedBorder) 19 | 20 | TextField("Base URL (Optional)", text: $settings.openAIBaseURL) 21 | .textFieldStyle(.roundedBorder) 22 | 23 | TextField("Model Name", text: $settings.openAIModel) 24 | .textFieldStyle(.roundedBorder) 25 | 26 | Text( 27 | "Default models: \(OpenAIConfig.defaultModel), gpt-4o, gpt-4o-mini, etc." 28 | ) 29 | .font(.caption) 30 | .foregroundColor(.secondary) 31 | 32 | Button("Get OpenAI API Key") { 33 | if let url = URL( 34 | string: "https://platform.openai.com/account/api-keys" 35 | ) { 36 | NSWorkspace.shared.open(url) 37 | } 38 | } 39 | .buttonStyle(.link) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Onboarding/Views/ProviderSettings/GeminiProviderSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeminiProviderSettingsView.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct GeminiProviderSettingsView: View { 11 | @ObservedObject var settings: AppSettings 12 | 13 | var body: some View { 14 | VStack(alignment: .leading, spacing: 12) { 15 | Text("Configure Google Gemini AI") 16 | .font(.headline) 17 | TextField("API Key", text: $settings.geminiApiKey) 18 | .textFieldStyle(.roundedBorder) 19 | 20 | Picker("Model", selection: $settings.geminiModel) { 21 | ForEach(GeminiModel.allCases, id: \.self) { model in 22 | Text(model.displayName).tag(model) 23 | } 24 | } 25 | .pickerStyle(.menu) 26 | .frame(maxWidth: .infinity, alignment: .leading) 27 | 28 | if settings.geminiModel == .custom { 29 | TextField("Custom Model Name", text: $settings.geminiCustomModel) 30 | .textFieldStyle(.roundedBorder) 31 | .padding(.top, 4) 32 | } 33 | 34 | Button("Get Gemini API Key") { 35 | if let url = URL(string: "https://aistudio.google.com/app/apikey") { 36 | NSWorkspace.shared.open(url) 37 | } 38 | } 39 | .buttonStyle(.link) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Onboarding/Views/ProviderSettings/OpenRouterProviderSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenRouterProviderSettingsView.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct OpenRouterProviderSettingsView: View { 11 | @ObservedObject var settings: AppSettings 12 | 13 | var body: some View { 14 | VStack(alignment: .leading, spacing: 12) { 15 | Text("Configure OpenRouter") 16 | .font(.headline) 17 | TextField("API Key", text: $settings.openRouterApiKey) 18 | .textFieldStyle(.roundedBorder) 19 | 20 | Picker("Model", selection: $settings.openRouterModel) { 21 | ForEach(OpenRouterModel.allCases, id: \.self) { model in 22 | Text(model.displayName).tag(model.rawValue) 23 | } 24 | } 25 | .pickerStyle(.menu) 26 | .frame(maxWidth: .infinity, alignment: .leading) 27 | 28 | if settings.openRouterModel == OpenRouterModel.custom.rawValue { 29 | TextField("Custom Model Name", text: $settings.openRouterCustomModel) 30 | .textFieldStyle(.roundedBorder) 31 | .padding(.top, 4) 32 | } 33 | 34 | Button("Get OpenRouter API Key") { 35 | if let url = URL(string: "https://openrouter.ai/keys") { 36 | NSWorkspace.shared.open(url) 37 | } 38 | } 39 | .buttonStyle(.link) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /macOS/WritingTools/Models/AI Providers/ModelConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MLXLMCommon 3 | 4 | // This struct defines a simple configuration for an MLX LLM. 5 | 6 | extension ModelConfiguration { 7 | 8 | public static func == (lhs: MLXLMCommon.ModelConfiguration, rhs: MLXLMCommon.ModelConfiguration) -> Bool { 9 | return lhs.name == rhs.name 10 | } 11 | // New configuration for Mistral Small 24B. 12 | // This uses the repository provided: "mlx-community/Mistral-Small-24B-Instruct-2501-4bit" 13 | public static let mistralSmall24B = ModelConfiguration( 14 | id: "mlx-community/Mistral-Small-24B-Instruct-2501-4bit" 15 | ) 16 | 17 | public static let qwen2_5_7b_1M_4bit = ModelConfiguration( 18 | id: "mlx-community/Qwen2.5-7B-Instruct-1M-4bit" 19 | ) 20 | 21 | public static let qwen2_5_3b_4bit = ModelConfiguration( 22 | id: "mlx-community/Qwen2.5-3B-Instruct-4bit" 23 | ) 24 | 25 | public static let deepseek_r1_qwen_14b_4bit = ModelConfiguration( 26 | id: "mlx-community/DeepSeek-R1-Distill-Qwen-14B-4bit" 27 | ) 28 | 29 | public static let phi4_mini_instruct_4bit = ModelConfiguration( 30 | id: "mlx-community/Phi-4-mini-instruct-4bit" 31 | ) 32 | 33 | public static let teuken_7B_4bit = ModelConfiguration( 34 | id: "stelterlab/Teuken-7B-instruct-commercial-v0.4-MLX-4bit" 35 | ) 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Windows_and_Linux/pot_files/CustomPopupWindow.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-01-28 19:47+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=CHARSET\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: ui/CustomPopupWindow.py:75 21 | msgid "Describe your change..." 22 | msgstr "" 23 | 24 | #: ui/CustomPopupWindow.py:75 25 | msgid "Ask your AI..." 26 | msgstr "" 27 | 28 | #: ui/CustomPopupWindow.py:109 29 | msgid "Proofread" 30 | msgstr "" 31 | 32 | #: ui/CustomPopupWindow.py:110 33 | msgid "Rewrite" 34 | msgstr "" 35 | 36 | #: ui/CustomPopupWindow.py:111 37 | msgid "Friendly" 38 | msgstr "" 39 | 40 | #: ui/CustomPopupWindow.py:112 41 | msgid "Professional" 42 | msgstr "" 43 | 44 | #: ui/CustomPopupWindow.py:113 45 | msgid "Concise" 46 | msgstr "" 47 | 48 | #: ui/CustomPopupWindow.py:114 49 | msgid "Summary" 50 | msgstr "" 51 | 52 | #: ui/CustomPopupWindow.py:115 53 | msgid "Key Points" 54 | msgstr "" 55 | 56 | #: ui/CustomPopupWindow.py:116 57 | msgid "Table" 58 | msgstr "" 59 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Onboarding/Views/ProviderSettings/AnthropicProviderSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnthropicProviderSettingsView.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AnthropicProviderSettingsView: View { 11 | @ObservedObject var settings: AppSettings 12 | 13 | var body: some View { 14 | VStack(alignment: .leading, spacing: 12) { 15 | Text("Configure Anthropic (Claude)") 16 | .font(.headline) 17 | TextField("API Key", text: $settings.anthropicApiKey) 18 | .textFieldStyle(.roundedBorder) 19 | 20 | Picker("Model", selection: $settings.anthropicModel) { 21 | ForEach(AnthropicModel.allCases, id: \.self) { model in 22 | Text(model.displayName).tag(model.rawValue) 23 | } 24 | } 25 | .pickerStyle(.menu) 26 | .frame(maxWidth: .infinity, alignment: .leading) 27 | 28 | TextField("Or Custom Model Name", text: $settings.anthropicModel) 29 | .textFieldStyle(.roundedBorder) 30 | .font(.caption) 31 | 32 | Text( 33 | "E.g., \(AnthropicModel.allCases.map { $0.rawValue }.joined(separator: ", "))" 34 | ) 35 | .font(.caption) 36 | .foregroundColor(.secondary) 37 | 38 | Button("Get Anthropic API Key") { 39 | if let url = URL(string: "https://console.anthropic.com/settings/keys") 40 | { 41 | NSWorkspace.shared.open(url) 42 | } 43 | } 44 | .buttonStyle(.link) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Windows_and_Linux/pot_files/SettingsWindow.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-01-28 19:47+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=CHARSET\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: ui/SettingsWindow.py:37 ui/SettingsWindow.py:156 ui/SettingsWindow.py:210 21 | msgid "Settings" 22 | msgstr "" 23 | 24 | #: ui/SettingsWindow.py:216 25 | msgid "Start on Boot" 26 | msgstr "" 27 | 28 | #: ui/SettingsWindow.py:223 29 | msgid "Shortcut Key:" 30 | msgstr "" 31 | 32 | #: ui/SettingsWindow.py:238 33 | msgid "Background Theme:" 34 | msgstr "" 35 | 36 | #: ui/SettingsWindow.py:243 37 | msgid "Blurry Gradient" 38 | msgstr "" 39 | 40 | #: ui/SettingsWindow.py:244 41 | msgid "Plain" 42 | msgstr "" 43 | 44 | #: ui/SettingsWindow.py:255 45 | msgid "Choose AI Provider:" 46 | msgstr "" 47 | 48 | #: ui/SettingsWindow.py:312 49 | msgid "Finish AI Setup" 50 | msgstr "" 51 | 52 | #: ui/SettingsWindow.py:312 53 | msgid "Save" 54 | msgstr "" 55 | 56 | #: ui/SettingsWindow.py:331 57 | msgid "Please restart Writing Tools for changes to take effect." 58 | msgstr "" 59 | -------------------------------------------------------------------------------- /macOS/WritingTools/Utilities/Custom Modifiers/LoadingModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct LoadingBorderModifier: ViewModifier { 4 | let isLoading: Bool 5 | @State private var rotation: Double = 0 6 | @Environment(\.colorScheme) var colorScheme 7 | 8 | private let accentColor = Color.blue 9 | 10 | func body(content: Content) -> some View { 11 | content 12 | .overlay( 13 | Group { 14 | if isLoading { 15 | ZStack { 16 | // Subtle background 17 | RoundedRectangle(cornerRadius: 8) 18 | .fill(Color.gray.opacity(0.05)) 19 | 20 | // Progress spinner that matches macOS style 21 | ProgressView() 22 | .controlSize(.small) 23 | .scaleEffect(0.8) 24 | } 25 | } 26 | } 27 | ) 28 | .disabled(isLoading) 29 | .animation(.easeInOut(duration: 0.2), value: isLoading) 30 | } 31 | } 32 | // Shared color extension 33 | extension Color { 34 | static let aiPink = Color(red: 255/255, green: 197/255, blue: 211/255) 35 | } 36 | 37 | // LoadingButtonStyle is now moved to CommandButton.swift 38 | 39 | // Extension to handle loading state buttons 40 | extension View { 41 | func loadingBorder(isLoading: Bool) -> some View { 42 | modifier(LoadingBorderModifier(isLoading: isLoading)) 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /macOS/WritingTools/Services/MigrationHelper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A utility class to help migrate from the old WritingOption/CustomCommand system 4 | /// to the new unified CommandModel system 5 | class MigrationHelper { 6 | static let shared = MigrationHelper() 7 | 8 | private let migrationCompletedKey = "command_migration_completed" 9 | 10 | private init() {} 11 | 12 | /// Checks if migration has been completed 13 | var isMigrationCompleted: Bool { 14 | return UserDefaults.standard.bool(forKey: migrationCompletedKey) 15 | } 16 | 17 | /// Performs migration from the old system to the new CommandManager system 18 | func migrateIfNeeded(commandManager: CommandManager, customCommandsManager: CustomCommandsManager) { 19 | // Skip if already migrated 20 | if isMigrationCompleted { 21 | return 22 | } 23 | 24 | // Migrate custom commands 25 | commandManager.migrateFromLegacySystems(customCommands: customCommandsManager.commands) 26 | 27 | // Mark migration as complete 28 | UserDefaults.standard.set(true, forKey: migrationCompletedKey) 29 | } 30 | 31 | /// Forces a re-migration (for testing or if needed) 32 | func forceMigration(commandManager: CommandManager, customCommandsManager: CustomCommandsManager) { 33 | // Reset migration flag 34 | UserDefaults.standard.set(false, forKey: migrationCompletedKey) 35 | 36 | // Perform migration 37 | migrateIfNeeded(commandManager: commandManager, customCommandsManager: customCommandsManager) 38 | } 39 | } -------------------------------------------------------------------------------- /macOS/WritingTools/Services/PasteboardRichText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PasteboardRichText.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 08.08.25. 6 | // 7 | 8 | import AppKit 9 | 10 | extension NSPasteboard { 11 | func readAttributedSelection() -> NSAttributedString? { 12 | // Prefer RTFD (common in Apple apps), then RTF, then HTML 13 | if let flatRtfd = data(forType: NSPasteboard.PasteboardType( 14 | "com.apple.flat-rtfd" 15 | )) { 16 | if let att = try? NSAttributedString( 17 | data: flatRtfd, 18 | options: [.documentType: NSAttributedString.DocumentType.rtfd], 19 | documentAttributes: nil 20 | ) { 21 | return att 22 | } 23 | } 24 | 25 | if let rtfd = data(forType: .rtfd) { 26 | if let att = try? NSAttributedString( 27 | data: rtfd, 28 | options: [.documentType: NSAttributedString.DocumentType.rtfd], 29 | documentAttributes: nil 30 | ) { 31 | return att 32 | } 33 | } 34 | 35 | if let rtf = data(forType: .rtf) { 36 | if let att = try? NSAttributedString( 37 | data: rtf, 38 | options: [.documentType: NSAttributedString.DocumentType.rtf], 39 | documentAttributes: nil 40 | ) { 41 | return att 42 | } 43 | } 44 | 45 | if let html = data(forType: .html) { 46 | if let att = try? NSAttributedString( 47 | data: html, 48 | options: [.documentType: NSAttributedString.DocumentType.html], 49 | documentAttributes: nil 50 | ) { 51 | return att 52 | } 53 | } 54 | 55 | return nil 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Onboarding/Views/ProviderSettings/OllamaProviderSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OllamaProviderSettingsView.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct OllamaProviderSettingsView: View { 11 | @ObservedObject var settings: AppSettings 12 | 13 | var body: some View { 14 | VStack(alignment: .leading, spacing: 12) { 15 | Text("Configure Ollama (Self-Hosted)") 16 | .font(.headline) 17 | TextField("Ollama Base URL", text: $settings.ollamaBaseURL) 18 | .textFieldStyle(.roundedBorder) 19 | 20 | TextField("Ollama Model Name", text: $settings.ollamaModel) 21 | .textFieldStyle(.roundedBorder) 22 | 23 | TextField("Keep Alive Time (e.g., 5m, 1h)", text: $settings.ollamaKeepAlive) 24 | .textFieldStyle(.roundedBorder) 25 | 26 | VStack(alignment: .leading, spacing: 6) { 27 | Text("Image Recognition Mode") 28 | .font(.subheadline) 29 | .foregroundColor(.secondary) 30 | Picker("Image Mode", selection: $settings.ollamaImageMode) { 31 | ForEach(OllamaImageMode.allCases) { mode in 32 | Text(mode.displayName).tag(mode) 33 | } 34 | } 35 | .pickerStyle(.segmented) 36 | 37 | Text("Use local OCR or Ollama's vision model for images.") 38 | .font(.caption) 39 | .foregroundColor(.secondary) 40 | } 41 | 42 | LinkText() 43 | 44 | Button("Ollama Documentation") { 45 | if let url = URL(string: "https://ollama.ai/download") { 46 | NSWorkspace.shared.open(url) 47 | } 48 | } 49 | .buttonStyle(.link) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Settings/Panes/AppearanceSettingsPane.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppearanceSettingsPane.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AppearanceSettingsPane: View { 11 | @ObservedObject var settings = AppSettings.shared 12 | @Binding var needsSaving: Bool 13 | var showOnlyApiSetup: Bool 14 | var saveButton: AnyView 15 | 16 | var body: some View { 17 | VStack(alignment: .leading, spacing: 24) { 18 | Text("Appearance Settings") 19 | .font(.headline) 20 | 21 | VStack(alignment: .leading, spacing: 12) { 22 | Text("Window Style") 23 | .font(.subheadline) 24 | .foregroundColor(.secondary) 25 | 26 | Text("Choose a window appearance that matches your preferences and context.") 27 | .font(.caption) 28 | .foregroundColor(.secondary) 29 | 30 | Picker("Theme", selection: $settings.themeStyle) { 31 | Text("Standard").tag("standard") 32 | Text("Gradient").tag("gradient") 33 | Text("Glass").tag("glass") 34 | Text("OLED").tag("oled") 35 | } 36 | .pickerStyle(.segmented) 37 | .padding(.vertical, 4) 38 | .onChange(of: settings.themeStyle) { _, _ in 39 | needsSaving = true 40 | } 41 | .help("Standard uses system backgrounds. Glass respects transparency preferences. OLED uses deep blacks.") 42 | } 43 | 44 | Spacer() 45 | 46 | if !showOnlyApiSetup { 47 | saveButton 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /macOS/WritingTools/Assets/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "WritingTools-macOS-Default-16x16@1x.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "WritingTools-macOS-Default-16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "WritingTools-macOS-Default-32x32@1x.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "WritingTools-macOS-Default-32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "WritingTools-macOS-Default-128x128@1x.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "WritingTools-macOS-Default-128x128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "WritingTools-macOS-Default-256x256@1x.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "WritingTools-macOS-Default-256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "WritingTools-macOS-Default-512x512@1x.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "WritingTools-macOS-Default-1024x1024@1x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /macOS/WritingTools/Services/OCRManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Vision 3 | import AppKit 4 | 5 | class OCRManager { 6 | static let shared = OCRManager() 7 | 8 | private init() {} 9 | 10 | // Extracts text from a single image Data object. 11 | func extractText(from imageData: Data) async -> String { 12 | await Task.detached(priority: .userInitiated) { 13 | guard let nsImage = NSImage(data: imageData), 14 | let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) 15 | else { return "" } 16 | 17 | let request = VNRecognizeTextRequest() 18 | request.recognitionLevel = .accurate 19 | request.usesLanguageCorrection = true 20 | 21 | // Perform the synchronous Vision work on this detached task 22 | let requestHandler = VNImageRequestHandler(cgImage: cgImage, options: [:]) 23 | do { 24 | try requestHandler.perform([request]) 25 | guard let observations = request.results else { 26 | return "" 27 | } 28 | let texts = observations.compactMap { observation in 29 | observation.topCandidates(1).first?.string 30 | } 31 | return texts.joined(separator: "\n") 32 | } catch { 33 | return "" 34 | } 35 | }.value 36 | } 37 | 38 | 39 | // Extracts text from an array of images. 40 | func extractText(from images: [Data]) async -> String { 41 | var combinedText = "" 42 | for imageData in images { 43 | let text = await extractText(from: imageData) 44 | if !text.isEmpty { 45 | combinedText += text + "\n" 46 | } 47 | } 48 | return combinedText.trimmingCharacters(in: .whitespacesAndNewlines) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Settings/Providers/OpenRouterSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenRouterSettingsView.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | import AppKit 10 | 11 | struct OpenRouterSettingsView: View { 12 | @ObservedObject var settings = AppSettings.shared 13 | @Binding var needsSaving: Bool 14 | 15 | var body: some View { 16 | VStack(alignment: .leading, spacing: 16) { 17 | Text("Configure OpenRouter") 18 | .font(.headline) 19 | TextField("API Key", text: $settings.openRouterApiKey) 20 | .textFieldStyle(.roundedBorder) 21 | .onChange(of: settings.openRouterApiKey) { _, _ in needsSaving = true } 22 | 23 | Picker("Model", selection: $settings.openRouterModel) { 24 | ForEach(OpenRouterModel.allCases, id: \.self) { model in 25 | Text(model.displayName).tag(model.rawValue) 26 | } 27 | } 28 | .pickerStyle(.menu) 29 | .frame(maxWidth: .infinity, alignment: .leading) 30 | .onChange(of: settings.openRouterModel) { _, _ in needsSaving = true } 31 | 32 | if settings.openRouterModel == OpenRouterModel.custom.rawValue { 33 | TextField("Custom Model Name", text: $settings.openRouterCustomModel) 34 | .textFieldStyle(.roundedBorder) 35 | .onChange(of: settings.openRouterCustomModel) { _, _ in needsSaving = true } 36 | .padding(.top, 4) 37 | } 38 | 39 | Button("Get OpenRouter API Key") { 40 | if let url = URL(string: "https://openrouter.ai/keys") { 41 | NSWorkspace.shared.open(url) 42 | } 43 | } 44 | .buttonStyle(.link) 45 | .help("Open OpenRouter to retrieve your API key.") 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /macOS/WritingTools/WritingTools.icon/icon.json: -------------------------------------------------------------------------------- 1 | { 2 | "fill" : { 3 | "linear-gradient" : [ 4 | "display-p3:0.75851,0.61642,1.00000,1.00000", 5 | "display-p3:0.43579,0.00000,0.96611,1.00000" 6 | ] 7 | }, 8 | "groups" : [ 9 | { 10 | "layers" : [ 11 | { 12 | "fill" : "none", 13 | "glass" : true, 14 | "image-name" : "ChatGPT Image Jul 20, 2025 at 03_29_57 PM.png", 15 | "name" : "ChatGPT Image Jul 20, 2025 at 03_29_57 PM", 16 | "opacity-specializations" : [ 17 | { 18 | "value" : 0.9 19 | }, 20 | { 21 | "appearance" : "dark", 22 | "value" : 0.85 23 | } 24 | ], 25 | "position" : { 26 | "scale" : 0.3, 27 | "translation-in-points" : [ 28 | 249.8515625, 29 | -321.5390625 30 | ] 31 | } 32 | } 33 | ], 34 | "shadow" : { 35 | "kind" : "neutral", 36 | "opacity" : 0.5 37 | }, 38 | "translucency" : { 39 | "enabled" : true, 40 | "value" : 0.5 41 | } 42 | }, 43 | { 44 | "layers" : [ 45 | { 46 | "blend-mode" : "normal", 47 | "glass" : false, 48 | "hidden" : false, 49 | "image-name" : "ChatGPT Image Jul 20, 2025 at 03_23_58 PM.png", 50 | "name" : "ChatGPT Image Jul 20, 2025 at 03_23_58 PM", 51 | "opacity" : 1, 52 | "position" : { 53 | "scale" : 1, 54 | "translation-in-points" : [ 55 | 0, 56 | 0 57 | ] 58 | } 59 | } 60 | ], 61 | "shadow" : { 62 | "kind" : "neutral", 63 | "opacity" : 0.5 64 | }, 65 | "translucency" : { 66 | "enabled" : true, 67 | "value" : 0.5 68 | } 69 | } 70 | ], 71 | "supported-platforms" : { 72 | "circles" : [ 73 | "watchOS" 74 | ], 75 | "squares" : "shared" 76 | } 77 | } -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Onboarding/Views/OnboardingWelcomeStep.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingWelcomeStep.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct OnboardingWelcomeStep: View { 11 | var body: some View { 12 | VStack(spacing: 16) { 13 | Image(systemName: "sparkles") 14 | .resizable() 15 | .scaledToFit() 16 | .frame(width: 60, height: 60) 17 | .foregroundColor(.accentColor) 18 | .padding(.bottom, 4) 19 | 20 | VStack(alignment: .leading, spacing: 10) { 21 | Label( 22 | "Improve your writing with one shortcut", 23 | systemImage: "square.and.pencil" 24 | ) 25 | Label( 26 | "Works in any app that supports copy & paste", 27 | systemImage: "app.badge" 28 | ) 29 | Label( 30 | "Preserves formatting for supported apps", 31 | systemImage: "note.text" 32 | ) 33 | Label( 34 | "Custom commands & per-command shortcuts", 35 | systemImage: "command.square.fill" 36 | ) 37 | } 38 | .font(.title3) 39 | .frame(maxWidth: .infinity, alignment: .leading) 40 | .padding(.top, 12) 41 | 42 | GroupBox { 43 | VStack(alignment: .leading, spacing: 8) { 44 | Text("How it works") 45 | .font(.headline) 46 | Text( 47 | """ 48 | WritingTools briefly copies your selection, sends it to your \ 49 | chosen AI provider (or a local model), and then pastes the \ 50 | result back—preserving formatting when supported. 51 | """ 52 | ) 53 | .foregroundColor(.secondary) 54 | .fixedSize(horizontal: false, vertical: true) 55 | } 56 | .padding(8) 57 | } 58 | 59 | Text("You can change any setting later in Settings.") 60 | .font(.footnote) 61 | .foregroundColor(.secondary) 62 | .frame(maxWidth: .infinity, alignment: .leading) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Onboarding/Views/PermissionRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PermissionRow.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PermissionRow: View { 11 | enum Status { 12 | case granted 13 | case missing 14 | } 15 | 16 | let icon: String 17 | let title: String 18 | let status: Status 19 | let explanation: String 20 | let primaryActionTitle: String 21 | let secondaryActionTitle: String 22 | let onPrimary: () -> Void 23 | let onSecondary: () -> Void 24 | 25 | var body: some View { 26 | HStack(alignment: .top, spacing: 14) { 27 | Image(systemName: icon) 28 | .font(.system(size: 28)) 29 | .foregroundColor(status == .granted ? .green : .blue) 30 | .frame(width: 36) 31 | 32 | VStack(alignment: .leading, spacing: 6) { 33 | HStack { 34 | Text(title).font(.headline) 35 | Spacer() 36 | statusBadge 37 | } 38 | 39 | Text(explanation) 40 | .foregroundColor(.secondary) 41 | .fixedSize(horizontal: false, vertical: true) 42 | 43 | HStack { 44 | Button(primaryActionTitle, action: onPrimary) 45 | .buttonStyle(.borderedProminent) 46 | .disabled(status == .granted) 47 | 48 | Button(secondaryActionTitle, action: onSecondary) 49 | .buttonStyle(.bordered) 50 | 51 | Spacer() 52 | } 53 | .padding(.top, 4) 54 | } 55 | } 56 | .padding(12) 57 | .background(Color(.controlBackgroundColor)) 58 | .cornerRadius(10) 59 | } 60 | 61 | @ViewBuilder 62 | private var statusBadge: some View { 63 | HStack(spacing: 6) { 64 | Image( 65 | systemName: status == .granted 66 | ? "checkmark.circle.fill" : "exclamationmark.circle.fill" 67 | ) 68 | .foregroundColor(status == .granted ? .green : .orange) 69 | Text(status == .granted ? "Granted" : "Required") 70 | .font(.caption) 71 | .foregroundColor(.secondary) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README's Linked Content/To Compile the Application Yourself.md: -------------------------------------------------------------------------------- 1 | # 👨‍💻 To compile the application yourself: 2 | 3 | ### Windows and Linux Version build instructions: 4 | Here's how to compile it with PyInstaller and a virtual environment: 5 | 6 | 1. First, create and activate a virtual environment: 7 | ```bash 8 | # Install virtualenv if you haven't already 9 | pip install virtualenv 10 | 11 | # Create a new virtual environment 12 | virtualenv myvenv 13 | 14 | # Activate it 15 | # On Windows: 16 | myvenv\Scripts\activate 17 | # On Linux: 18 | source myvenv/bin/activate 19 | ``` 20 | 21 | 2. Once activated, install the required packages: 22 | 23 | ```bash 24 | pip install -r requirements.txt 25 | ``` 26 | 27 | 3. Build Writing Tools: 28 | ```bash 29 | python pyinstaller-build-script.py 30 | ``` 31 | 32 | ### macOS Version (by [Aryamirsepasi](https://github.com/Aryamirsepasi)) build instructions: 33 | 34 | 1. **Install Xcode** 35 | - Download and install Xcode from the App Store 36 | - Launch Xcode once installed and complete any additional component installations 37 | 38 | 2. **Clone the Repository** 39 | - Open Terminal and navigate to the directory where you want to store the project: 40 | ```bash 41 | git clone https://github.com/theJayTea/WritingTools.git 42 | ``` 43 | 44 | 3. **Open in Xcode** 45 | - Open Xcode 46 | - Select "Open an existing project..." from the options. 47 | - Navigate to the macOS folder within the WritingTools directory that you cloned previously, and select "writing-tools.xcodeproj" 48 | 49 | 4. **Configure Project Settings** 50 | - In Xcode, select the project in the Navigator pane. 51 | - Under "Targets", select "writing-tools" 52 | - Set the following: 53 | - Deployment Target: macOS 14.0 54 | - Signing & Capabilities: Add your development team 55 | 56 | 5. **Build and Run** 57 | - In Xcode, select "My Mac" as the run destination 58 | - Click the Play button or press ⌘R to build and run 59 | 60 | ### [**◀️ Back to main page**](https://github.com/theJayTea/WritingTools) -------------------------------------------------------------------------------- /Windows_and_Linux/pot_files/OnboardingWindow.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-01-28 19:47+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: ui/OnboardingWindow.py:26 ui/OnboardingWindow.py:42 21 | msgid "Welcome to Writing Tools" 22 | msgstr "" 23 | 24 | #: ui/OnboardingWindow.py:47 25 | msgid "" 26 | "Instantly optimize your writing with AI by selecting your text and invoking " 27 | "Writing Tools with \"ctrl+space\", anywhere." 28 | msgstr "" 29 | 30 | #: ui/OnboardingWindow.py:49 31 | msgid "" 32 | "Get a summary you can chat with of articles, YouTube videos, or documents by " 33 | "select all text with \"ctrl+a\"" 34 | msgstr "" 35 | 36 | #: ui/OnboardingWindow.py:50 37 | msgid "" 38 | "(or select the YouTube transcript from its description), invoking Writing " 39 | "Tools, and choosing Summary." 40 | msgstr "" 41 | 42 | #: ui/OnboardingWindow.py:52 43 | msgid "" 44 | "Chat with AI anytime by invoking Writing Tools without selecting any text." 45 | msgstr "" 46 | 47 | #: ui/OnboardingWindow.py:54 48 | msgid "Supports an extensive range of AI models:" 49 | msgstr "" 50 | 51 | #: ui/OnboardingWindow.py:55 52 | msgid "Gemini 2.0" 53 | msgstr "" 54 | 55 | #: ui/OnboardingWindow.py:56 56 | msgid "ANY OpenAI Compatible API — including local LLMs!" 57 | msgstr "" 58 | 59 | #: ui/OnboardingWindow.py:77 60 | msgid "Choose your theme:" 61 | msgstr "" 62 | 63 | #: ui/OnboardingWindow.py:82 64 | msgid "Gradient" 65 | msgstr "" 66 | 67 | #: ui/OnboardingWindow.py:83 68 | msgid "Plain" 69 | msgstr "" 70 | 71 | #: ui/OnboardingWindow.py:92 72 | msgid "Next" 73 | msgstr "" 74 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Settings/Providers/MistralSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MistralSettingsView.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MistralSettingsView: View { 11 | @ObservedObject var settings = AppSettings.shared 12 | @Binding var needsSaving: Bool 13 | 14 | var body: some View { 15 | VStack(alignment: .leading, spacing: 16) { 16 | Group { 17 | VStack(alignment: .leading, spacing: 8) { 18 | Text("API Configuration") 19 | .font(.subheadline) 20 | .foregroundColor(.secondary) 21 | 22 | TextField("API Key", text: $settings.mistralApiKey) 23 | .textFieldStyle(.roundedBorder) 24 | .onChange(of: settings.mistralApiKey) { _, _ in 25 | needsSaving = true 26 | } 27 | } 28 | 29 | VStack(alignment: .leading, spacing: 8) { 30 | Text("Model Selection") 31 | .font(.subheadline) 32 | .foregroundColor(.secondary) 33 | 34 | Picker("Model", selection: $settings.mistralModel) { 35 | ForEach(MistralModel.allCases, id: \.self) { model in 36 | Text(model.displayName).tag(model.rawValue) 37 | } 38 | } 39 | .pickerStyle(.menu) 40 | .frame(maxWidth: .infinity, alignment: .leading) 41 | .onChange(of: settings.mistralModel) { _, _ in 42 | needsSaving = true 43 | } 44 | } 45 | } 46 | .padding(.bottom, 4) 47 | 48 | Button("Get Mistral API Key") { 49 | if let url = URL(string: "https://console.mistral.ai/api-keys/") { 50 | NSWorkspace.shared.open(url) 51 | } 52 | } 53 | .buttonStyle(.link) 54 | .help("Open Mistral console to create an API key.") 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Onboarding/Views/OnboardingFinishStep.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingFinishStep.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct OnboardingFinishStep: View { 11 | var appState: AppState 12 | var onOpenCommandsManager: () -> Void 13 | var onFinish: () -> Void 14 | 15 | var body: some View { 16 | VStack(alignment: .leading, spacing: 16) { 17 | GroupBox { 18 | VStack(alignment: .leading, spacing: 8) { 19 | Label("You're ready to go!", systemImage: "checkmark.seal.fill") 20 | .font(.title2) 21 | .foregroundColor(.green) 22 | 23 | Text( 24 | """ 25 | Press your global shortcut to open the popup. Select text or \ 26 | images in any app and run a command. Built‑in commands are \ 27 | available and you can add your own. 28 | """ 29 | ) 30 | .foregroundColor(.secondary) 31 | .fixedSize(horizontal: false, vertical: true) 32 | } 33 | .padding(8) 34 | } 35 | 36 | GroupBox("Tips") { 37 | VStack(alignment: .leading, spacing: 6) { 38 | Label( 39 | "Use Proofread to preserve formatting while fixing grammar/spelling.", 40 | systemImage: "text.badge.checkmark" 41 | ) 42 | Label( 43 | "Assign per‑command shortcuts for instant actions without the popup.", 44 | systemImage: "keyboard" 45 | ) 46 | Label( 47 | "Local LLM keeps data on‑device; cloud providers receive selected content for processing.", 48 | systemImage: "lock.shield" 49 | ) 50 | } 51 | .foregroundColor(.secondary) 52 | .padding(8) 53 | } 54 | 55 | Text( 56 | "You can revisit onboarding anytime from Settings > General > Onboarding." 57 | ) 58 | .font(.footnote) 59 | .foregroundColor(.secondary) 60 | 61 | HStack { 62 | Button("Open Commands Manager") { 63 | onOpenCommandsManager() 64 | } 65 | .buttonStyle(.bordered) 66 | 67 | Spacer() 68 | 69 | Button("Finish and Start Using WritingTools") { 70 | onFinish() 71 | } 72 | .buttonStyle(.borderedProminent) 73 | } 74 | .padding(.top, 8) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /macOS/WritingTools/Models/AI Providers/LocalModelInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MLXVLM 3 | import MLXLLM 4 | import MLXLMCommon 5 | 6 | enum LocalModelType: String, CaseIterable, Identifiable { 7 | // LLM Models 8 | case llama = "llama3_2_3B_4bit" 9 | case qwen3_4b = "qwen3_4b_4bit" 10 | case gemma3n = "gemma3n_E4B_it_lm_4bit" 11 | 12 | // VLM Models 13 | case gemma3 = "gemma-3-4b-it-qat-4bit" 14 | case qwen25VL = "qwen2_5vl_3b_instruct_4bit" 15 | case qwen3VL = "qwen3vl_3b_instruct_4bit" 16 | 17 | var id: String { self.rawValue } 18 | 19 | // User-friendly display names 20 | var displayName: String { 21 | switch self { 22 | // LLM Models 23 | case .llama: return "Llama 3.2 (3B, 4-bit)" 24 | case .qwen3_4b: return "Qwen 3.0 (4B, 4-bit)" 25 | case .gemma3n: return "Gemma 3n IT (4B, 4-bit)" 26 | 27 | // VLM Models 28 | case .gemma3: return "Gemma 3 VL (4B, 4-bit) 📷 (Recommended)" 29 | case .qwen3VL: return "Qwen 3 VL (4B, 4-bit) 📷" 30 | case .qwen25VL: return "Qwen 2.5 VL (3B, 4-bit) 📷" 31 | 32 | 33 | } 34 | } 35 | 36 | // Is this a vision-capable model? 37 | var isVisionModel: Bool { 38 | switch self { 39 | case .qwen25VL: 40 | return true 41 | case .gemma3: 42 | return true 43 | case .qwen3VL: 44 | return true 45 | default: 46 | return false 47 | } 48 | } 49 | 50 | // Corresponding ModelConfiguration from LLMRegistry or VLMRegistry 51 | var configuration: ModelConfiguration { 52 | switch self { 53 | // LLM configurations 54 | case .llama: return LLMRegistry.llama3_2_3B_4bit 55 | case .qwen3_4b: return LLMRegistry.qwen3_4b_4bit 56 | case .gemma3n: return LLMRegistry.gemma3n_E4B_it_lm_4bit 57 | 58 | // VLM configurations 59 | case .gemma3: return VLMRegistry.gemma3_4B_qat_4bit 60 | case .qwen25VL: return VLMRegistry.qwen2_5VL3BInstruct4Bit 61 | case .qwen3VL: return VLMRegistry.qwen3VL4BInstruct4Bit 62 | } 63 | } 64 | 65 | // Helper to get enum case from configuration ID string 66 | static func from(id: String?) -> LocalModelType? { 67 | guard let id = id else { return nil } 68 | return LocalModelType(rawValue: id) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Settings/Providers/OpenAISettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenAISettingsView.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | import AppKit 10 | 11 | struct OpenAISettingsView: View { 12 | @ObservedObject var settings = AppSettings.shared 13 | @Binding var needsSaving: Bool 14 | 15 | var body: some View { 16 | VStack(alignment: .leading, spacing: 16) { 17 | Group { 18 | VStack(alignment: .leading, spacing: 8) { 19 | Text("API Configuration") 20 | .font(.subheadline) 21 | .foregroundColor(.secondary) 22 | 23 | TextField("API Key", text: $settings.openAIApiKey) 24 | .textFieldStyle(.roundedBorder) 25 | .onChange(of: settings.openAIApiKey) { _, _ in 26 | needsSaving = true 27 | } 28 | 29 | TextField("Base URL", text: $settings.openAIBaseURL) 30 | .textFieldStyle(.roundedBorder) 31 | .onChange(of: settings.openAIBaseURL) { _, _ in 32 | needsSaving = true 33 | } 34 | } 35 | 36 | VStack(alignment: .leading, spacing: 8) { 37 | Text("Model Configuration") 38 | .font(.subheadline) 39 | .foregroundColor(.secondary) 40 | 41 | TextField("Model Name", text: $settings.openAIModel) 42 | .textFieldStyle(.roundedBorder) 43 | .onChange(of: settings.openAIModel) { _, _ in 44 | needsSaving = true 45 | } 46 | 47 | Text("OpenAI models include: gpt-4o, gpt-4o-mini, etc.") 48 | .font(.caption) 49 | .foregroundColor(.secondary) 50 | } 51 | 52 | } 53 | .padding(.bottom, 4) 54 | 55 | Button("Get OpenAI API Key") { 56 | if let url = URL(string: "https://platform.openai.com/account/api-keys") { 57 | NSWorkspace.shared.open(url) 58 | } 59 | } 60 | .buttonStyle(.link) 61 | .help("Open OpenAI dashboard to create an API key.") 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /macOS/WritingTools/Services/UpdateChecker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AppKit 3 | 4 | @Observable 5 | final class UpdateChecker { 6 | static let shared = UpdateChecker() 7 | private let currentVersion = 4.2 // Current app version 8 | private let updateCheckURL = "https://raw.githubusercontent.com/theJayTea/WritingTools/main/macOS/Latest_Version_for_Update_Check.txt" 9 | private let updateDownloadURL = "https://github.com/theJayTea/WritingTools/releases" 10 | 11 | var isCheckingForUpdates = false 12 | var updateAvailable = false 13 | var checkError: String? 14 | 15 | private init() {} 16 | 17 | @MainActor 18 | func checkForUpdates() async { 19 | isCheckingForUpdates = true 20 | checkError = nil 21 | 22 | defer { 23 | isCheckingForUpdates = false 24 | } 25 | 26 | guard let url = URL(string: updateCheckURL) else { 27 | checkError = "Invalid update check URL" 28 | return 29 | } 30 | 31 | do { 32 | let (data, _) = try await URLSession.shared.data(from: url) 33 | 34 | // Print raw data for debugging 35 | if let rawString = String(data: data, encoding: .utf8) { 36 | print("Raw version data: '\(rawString)'") 37 | } 38 | 39 | // Clean up the version string more aggressively 40 | let cleanedString = String(data: data, encoding: .utf8)? 41 | .components(separatedBy: .newlines) 42 | .first? 43 | .trimmingCharacters(in: .whitespacesAndNewlines) 44 | .replacingOccurrences(of: "\n", with: "") 45 | .replacingOccurrences(of: "\r", with: "") 46 | 47 | if let versionString = cleanedString, 48 | !versionString.isEmpty, 49 | let latestVersion = Double(versionString) { // Changed to Double 50 | print("Parsed version: \(latestVersion)") 51 | updateAvailable = latestVersion > currentVersion 52 | } else { 53 | checkError = "Invalid version format" 54 | if let cleanedString = cleanedString { 55 | print("Failed to parse version from: '\(cleanedString)'") 56 | } 57 | } 58 | } catch { 59 | checkError = "Failed to check for updates: \(error.localizedDescription)" 60 | } 61 | } 62 | 63 | func openReleasesPage() { 64 | if let url = URL(string: updateDownloadURL) { 65 | NSWorkspace.shared.open(url) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Settings/Providers/GeminiSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeminiSettingsView.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct GeminiSettingsView: View { 11 | @ObservedObject var settings = AppSettings.shared 12 | @Binding var needsSaving: Bool 13 | 14 | var body: some View { 15 | VStack(alignment: .leading, spacing: 16) { 16 | Group { 17 | VStack(alignment: .leading, spacing: 8) { 18 | Text("API Configuration") 19 | .font(.subheadline) 20 | .foregroundColor(.secondary) 21 | 22 | TextField("API Key", text: $settings.geminiApiKey) 23 | .textFieldStyle(.roundedBorder) 24 | .onChange(of: settings.geminiApiKey) { _, _ in 25 | needsSaving = true 26 | } 27 | } 28 | 29 | VStack(alignment: .leading, spacing: 8) { 30 | Text("Model Selection") 31 | .font(.subheadline) 32 | .foregroundColor(.secondary) 33 | 34 | Picker("Model", selection: $settings.geminiModel) { 35 | ForEach(GeminiModel.allCases, id: \.self) { model in 36 | Text(model.displayName).tag(model) 37 | } 38 | } 39 | .pickerStyle(.menu) 40 | .frame(maxWidth: .infinity, alignment: .leading) 41 | .onChange(of: settings.geminiModel) { _, _ in 42 | needsSaving = true 43 | } 44 | 45 | if settings.geminiModel == .custom { 46 | TextField("Custom Model Name", text: $settings.geminiCustomModel) 47 | .textFieldStyle(.roundedBorder) 48 | .onChange(of: settings.geminiCustomModel) { _, _ in 49 | needsSaving = true 50 | } 51 | .padding(.top, 4) 52 | } 53 | } 54 | } 55 | .padding(.bottom, 4) 56 | 57 | Button("Get API Key") { 58 | if let url = URL(string: "https://aistudio.google.com/app/apikey") { 59 | NSWorkspace.shared.open(url) 60 | } 61 | } 62 | .buttonStyle(.link) 63 | .help("Open Google AI Studio to generate an API key.") 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Settings/Providers/AnthropicSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnthropicSettingsView.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | import AppKit 10 | 11 | struct AnthropicSettingsView: View { 12 | @ObservedObject var settings = AppSettings.shared 13 | @Binding var needsSaving: Bool 14 | 15 | var body: some View { 16 | VStack(alignment: .leading, spacing: 16) { 17 | Group { 18 | VStack(alignment: .leading, spacing: 8) { 19 | Text("API Configuration") 20 | .font(.subheadline) 21 | .foregroundColor(.secondary) 22 | 23 | TextField("API Key", text: $settings.anthropicApiKey) 24 | .textFieldStyle(.roundedBorder) 25 | .onChange(of: settings.anthropicApiKey) { _, _ in needsSaving = true } 26 | } 27 | 28 | VStack(alignment: .leading, spacing: 8) { 29 | Text("Model Selection") 30 | .font(.subheadline) 31 | .foregroundColor(.secondary) 32 | 33 | Picker("Model", selection: $settings.anthropicModel) { 34 | ForEach(AnthropicModel.allCases, id: \.self) { model in 35 | Text(model.displayName).tag(model.rawValue) 36 | } 37 | } 38 | .pickerStyle(.menu) 39 | .frame(maxWidth: .infinity, alignment: .leading) 40 | .onChange(of: settings.anthropicModel) { _, _ in needsSaving = true } 41 | 42 | TextField("Or Custom Model Name", text: $settings.anthropicModel) 43 | .textFieldStyle(.roundedBorder) 44 | .font(.caption) 45 | .onChange(of: settings.anthropicModel) { _, _ in needsSaving = true } 46 | Text("E.g., \(AnthropicModel.claude45Haiku.rawValue), \(AnthropicModel.claude45Sonnet.rawValue), etc.") 47 | .font(.caption) 48 | .foregroundColor(.secondary) 49 | } 50 | } 51 | .padding(.bottom, 4) 52 | 53 | Button("Get Anthropic API Key") { 54 | if let url = URL(string: "https://console.anthropic.com/settings/keys") { 55 | NSWorkspace.shared.open(url) 56 | } 57 | } 58 | .buttonStyle(.link) 59 | .help("Open Anthropic console to create or view your API key.") 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Windows_and_Linux/pot_files/AboutWindow.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-01-28 19:47+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=CHARSET\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: ui/AboutWindow.py:44 21 | msgid "About Writing Tools" 22 | msgstr "" 23 | 24 | #: ui/AboutWindow.py:49 25 | msgid "" 26 | "Writing Tools is a free & lightweight tool that helps you improve your " 27 | "writing with AI, similar to Apple's new Apple Intelligence feature. It works " 28 | "with an extensive range of AI LLMs, both online and locally run." 29 | msgstr "" 30 | 31 | #: ui/AboutWindow.py:54 32 | msgid "Created with care by Jesai, a high school student." 33 | msgstr "" 34 | 35 | #: ui/AboutWindow.py:55 36 | msgid "Feel free to check out my other AI app" 37 | msgstr "" 38 | 39 | #: ui/AboutWindow.py:55 40 | msgid "It's a novel AI tutor that's free on the Google Play Store :)" 41 | msgstr "" 42 | 43 | #: ui/AboutWindow.py:56 44 | msgid "Contact me" 45 | msgstr "" 46 | 47 | #: ui/AboutWindow.py:60 48 | msgid "" 49 | "Writing Tools would not be where it is today without its amazing " 50 | "contributors" 51 | msgstr "" 52 | 53 | #: ui/AboutWindow.py:62 54 | msgid "" 55 | "Extensively refactored Writing Tools and added OpenAI Compatible API " 56 | "support, streamed responses, and the text generation mode when no text is " 57 | "selected." 58 | msgstr "" 59 | 60 | #: ui/AboutWindow.py:64 61 | msgid "" 62 | "Added Linux support, switched to the pynput API to improve Windows " 63 | "stability. Added Ollama API support, custom options and localization. Fixed " 64 | "misc. bugs and added graceful termination support by handling SIGINT signal." 65 | msgstr "" 66 | 67 | #: ui/AboutWindow.py:66 68 | msgid "" 69 | "Helped add dark mode, the plain theme, tray menu fixes, and UI improvements." 70 | msgstr "" 71 | 72 | #: ui/AboutWindow.py:68 73 | msgid "Helped improve the reliability of text selection." 74 | msgstr "" 75 | 76 | #: ui/AboutWindow.py:70 77 | msgid "Made the rounded corners anti-aliased & prettier." 78 | msgstr "" 79 | 80 | #: ui/AboutWindow.py:72 81 | msgid "" 82 | "Significantly improved the About window, making it scrollable and cleaning " 83 | "things up. Also improved our .gitignore & requirements.txt." 84 | msgstr "" 85 | 86 | #: ui/AboutWindow.py:74 87 | msgid "Helped add the start-on-boot setting." 88 | msgstr "" 89 | -------------------------------------------------------------------------------- /Windows_and_Linux/update_checker.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import time 4 | from urllib.error import HTTPError 5 | from urllib.request import URLError, urlopen 6 | 7 | CURRENT_VERSION = 8 8 | UPDATE_CHECK_URL = "https://raw.githubusercontent.com/theJayTea/WritingTools/main/Windows_and_Linux/Latest_Version_for_Update_Check.txt" 9 | UPDATE_DOWNLOAD_URL = "https://github.com/theJayTea/WritingTools/releases" 10 | 11 | class UpdateChecker: 12 | def __init__(self, app): 13 | self.app = app 14 | 15 | def _fetch_latest_version(self): 16 | """ 17 | Fetch the latest version number from GitHub. 18 | Returns the version number or None if failed. 19 | """ 20 | try: 21 | with urlopen(UPDATE_CHECK_URL, timeout=5) as response: 22 | data = response.read().decode('utf-8').strip() 23 | try: 24 | return int(data) 25 | except ValueError: 26 | logging.warning(f"Invalid version number format: {data}") 27 | return None 28 | except (URLError, HTTPError) as e: 29 | logging.warning(f"Failed to fetch version info: {e}") 30 | return None 31 | except Exception as e: 32 | logging.error(f"Unexpected error checking for updates: {e}") 33 | return None 34 | 35 | def _retry_fetch_version(self): 36 | """ 37 | Attempt to fetch version with one retry. 38 | """ 39 | result = self._fetch_latest_version() 40 | if result is None: 41 | # Wait 2 seconds before retry 42 | time.sleep(2) 43 | result = self._fetch_latest_version() 44 | return result 45 | 46 | def check_updates(self): 47 | """ 48 | Check if an update is available. 49 | Always checks against cloud value and updates config accordingly. 50 | Returns True if an update is available. 51 | """ 52 | latest_version = self._retry_fetch_version() 53 | 54 | if latest_version is None: 55 | return False 56 | 57 | update_available = latest_version > CURRENT_VERSION 58 | 59 | # Always update config with fresh status 60 | if "update_available" in self.app.config or update_available: 61 | self.app.config["update_available"] = update_available 62 | self.app.save_config(self.app.config) 63 | 64 | return update_available 65 | 66 | def check_updates_async(self): 67 | """ 68 | Perform the update check in a background thread. 69 | """ 70 | def check_thread(): 71 | self.check_updates() 72 | 73 | thread = threading.Thread(target=check_thread, daemon=True) 74 | thread.start() -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Onboarding/Views/OnboardingCustomizationStep.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingCustomizationStep.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | import KeyboardShortcuts 10 | 11 | struct OnboardingCustomizationStep: View { 12 | @ObservedObject var appState: AppState 13 | @ObservedObject var settings: AppSettings 14 | 15 | var body: some View { 16 | VStack(alignment: .leading, spacing: 20) { 17 | GroupBox("Global Shortcut") { 18 | VStack(alignment: .leading, spacing: 8) { 19 | Text( 20 | "Set the keyboard shortcut to activate WritingTools from anywhere." 21 | ) 22 | .font(.caption) 23 | .foregroundColor(.secondary) 24 | 25 | KeyboardShortcuts.Recorder( 26 | "Activate WritingTools:", 27 | name: .showPopup 28 | ) 29 | } 30 | .padding(.vertical, 4) 31 | } 32 | 33 | GroupBox("Appearance Theme") { 34 | VStack(alignment: .leading, spacing: 8) { 35 | Text("Choose how the popup window looks.") 36 | .font(.caption) 37 | .foregroundColor(.secondary) 38 | 39 | Picker("Theme", selection: $settings.themeStyle) { 40 | Text("Standard").tag("standard") 41 | Text("Gradient").tag("gradient") 42 | Text("Glass").tag("glass") 43 | Text("OLED").tag("oled") 44 | } 45 | .pickerStyle(.segmented) 46 | } 47 | .padding(.vertical, 4) 48 | } 49 | 50 | GroupBox("AI Provider") { 51 | VStack(alignment: .leading, spacing: 8) { 52 | Text("Select the AI service you want to use.") 53 | .font(.caption) 54 | .foregroundColor(.secondary) 55 | 56 | Picker("Provider", selection: $settings.currentProvider) { 57 | if LocalModelProvider.isAppleSilicon { 58 | Text("Local LLM (On-Device)").tag("local") 59 | } 60 | Text("Gemini AI (Google)").tag("gemini") 61 | Text("OpenAI (ChatGPT)").tag("openai") 62 | Text("Mistral AI").tag("mistral") 63 | Text("Anthropic (Claude)").tag("anthropic") 64 | Text("Ollama (Self-Hosted)").tag("ollama") 65 | Text("OpenRouter").tag("openrouter") 66 | } 67 | .pickerStyle(.menu) 68 | .frame(maxWidth: .infinity, alignment: .leading) 69 | .onChange(of: settings.currentProvider) { _, newValue in 70 | if newValue == "local", !LocalModelProvider.isAppleSilicon { 71 | settings.currentProvider = "gemini" 72 | } 73 | } 74 | 75 | GroupBox("Provider Configuration") { 76 | ProviderSettingsContainerView(settings: settings, appState: appState) 77 | } 78 | .padding(.top, 8) 79 | 80 | Text("You can always adjust these later in Settings.") 81 | .font(.footnote) 82 | .foregroundColor(.secondary) 83 | } 84 | .padding(.vertical, 4) 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Settings/Panes/AIProviderSettingsPane.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AIProviderSettingsPane.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | import AppKit 10 | 11 | struct AIProviderSettingsPane: View { 12 | @ObservedObject var appState: AppState 13 | @ObservedObject var settings = AppSettings.shared 14 | @Binding var needsSaving: Bool 15 | var showOnlyApiSetup: Bool 16 | var saveButton: AnyView 17 | var completeSetupButton: AnyView 18 | 19 | var body: some View { 20 | VStack(alignment: .leading, spacing: 20) { 21 | Text("AI Provider Settings") 22 | .font(.headline) 23 | 24 | VStack(alignment: .leading, spacing: 12) { 25 | Text("Select AI Service") 26 | .font(.subheadline) 27 | .foregroundColor(.secondary) 28 | 29 | Picker("Provider", selection: $settings.currentProvider) { 30 | if LocalModelProvider.isAppleSilicon { 31 | Text("Local LLM").tag("local") 32 | } 33 | Text("Gemini AI").tag("gemini") 34 | Text("OpenAI").tag("openai") 35 | Text("Anthropic").tag("anthropic") 36 | Text("Mistral AI").tag("mistral") 37 | Text("Ollama").tag("ollama") 38 | Text("OpenRouter").tag("openrouter") 39 | } 40 | .pickerStyle(.menu) 41 | .frame(maxWidth: .infinity, alignment: .leading) 42 | .onChange(of: settings.currentProvider) { _, newValue in 43 | if newValue == "local" && !LocalModelProvider.isAppleSilicon { 44 | settings.currentProvider = "gemini" 45 | } 46 | needsSaving = true 47 | } 48 | .help("Select which AI service to use for processing.") 49 | } 50 | 51 | Divider() 52 | .padding(.vertical, 2) 53 | 54 | ScrollView { 55 | VStack(alignment: .leading, spacing: 0) { 56 | if settings.currentProvider == "gemini" { 57 | GeminiSettingsView(needsSaving: $needsSaving) 58 | } else if settings.currentProvider == "mistral" { 59 | MistralSettingsView(needsSaving: $needsSaving) 60 | } else if settings.currentProvider == "anthropic" { 61 | AnthropicSettingsView(needsSaving: $needsSaving) 62 | } else if settings.currentProvider == "openai" { 63 | OpenAISettingsView(needsSaving: $needsSaving) 64 | } else if settings.currentProvider == "ollama" { 65 | OllamaSettingsView(needsSaving: $needsSaving) 66 | } else if settings.currentProvider == "openrouter" { 67 | OpenRouterSettingsView(needsSaving: $needsSaving) 68 | } else if settings.currentProvider == "local" { 69 | LocalLLMSettingsView(provider: appState.localLLMProvider) 70 | } 71 | } 72 | .frame(maxWidth: .infinity, alignment: .leading) 73 | } 74 | 75 | if !showOnlyApiSetup { 76 | saveButton 77 | } else { 78 | completeSetupButton 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Settings/Providers/OllamaSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OllamaSettingsView.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | import AppKit 10 | 11 | struct OllamaSettingsView: View { 12 | @ObservedObject var settings = AppSettings.shared 13 | @Binding var needsSaving: Bool 14 | 15 | var body: some View { 16 | VStack(alignment: .leading, spacing: 16) { 17 | Group { 18 | VStack(alignment: .leading, spacing: 8) { 19 | Text("Connection Settings") 20 | .font(.subheadline) 21 | .foregroundColor(.secondary) 22 | 23 | TextField("Ollama Base URL", text: $settings.ollamaBaseURL) 24 | .textFieldStyle(.roundedBorder) 25 | .onChange(of: settings.ollamaBaseURL) { _, _ in 26 | needsSaving = true 27 | } 28 | } 29 | 30 | VStack(alignment: .leading, spacing: 8) { 31 | Text("Model Configuration") 32 | .font(.subheadline) 33 | .foregroundColor(.secondary) 34 | 35 | TextField("Ollama Model", text: $settings.ollamaModel) 36 | .textFieldStyle(.roundedBorder) 37 | .onChange(of: settings.ollamaModel) { _, _ in 38 | needsSaving = true 39 | } 40 | 41 | TextField("Keep Alive Time", text: $settings.ollamaKeepAlive) 42 | .textFieldStyle(.roundedBorder) 43 | .onChange(of: settings.ollamaKeepAlive) { _, _ in 44 | needsSaving = true 45 | } 46 | } 47 | 48 | VStack(alignment: .leading, spacing: 8) { 49 | Text("Image Recognition") 50 | .font(.subheadline) 51 | .foregroundColor(.secondary) 52 | 53 | Picker("Image Mode", selection: $settings.ollamaImageMode) { 54 | ForEach(OllamaImageMode.allCases) { mode in 55 | Text(mode.displayName).tag(mode) 56 | } 57 | } 58 | .pickerStyle(.segmented) 59 | .onChange(of: settings.ollamaImageMode) { _, _ in 60 | needsSaving = true 61 | } 62 | 63 | Text("Choose between performing OCR locally or using an Ollama vision-enabled model for image input.") 64 | .font(.caption) 65 | .foregroundColor(.secondary) 66 | } 67 | 68 | VStack(alignment: .leading, spacing: 4) { 69 | Text("Documentation") 70 | .font(.subheadline) 71 | .foregroundColor(.secondary) 72 | 73 | LinkText() 74 | } 75 | } 76 | .padding(.bottom, 4) 77 | 78 | Button("Ollama Documentation") { 79 | if let url = URL(string: "https://ollama.ai/download") { 80 | NSWorkspace.shared.open(url) 81 | } 82 | } 83 | .buttonStyle(.link) 84 | .help("Open Ollama download and documentation page in your browser.") 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /macOS/WritingTools/Utilities/Custom Modifiers/AppleStyleTextFieldModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AppleStyleTextFieldModifier: ViewModifier { 4 | @Environment(\.colorScheme) var colorScheme 5 | let isLoading: Bool 6 | let text: String 7 | let onSubmit: () -> Void 8 | 9 | @State private var isAnimating: Bool = false 10 | @State private var isHovered: Bool = false 11 | 12 | private let animationDuration = 0.3 13 | private let animationNanoseconds: UInt64 = 300_000_000 // 0.3 seconds 14 | 15 | func body(content: Content) -> some View { 16 | ZStack(alignment: .trailing) { 17 | HStack(spacing: 0) { 18 | content 19 | .font(.system(size: 14)) 20 | .foregroundColor(colorScheme == .dark ? .white : .primary) 21 | .padding(12) 22 | .onSubmit { 23 | performSubmitAnimation() 24 | } 25 | 26 | Spacer(minLength: 0) 27 | } 28 | 29 | // Integrated send button with more subtle styling 30 | if !text.isEmpty { 31 | Button(action: performSubmitAnimation) { 32 | Image(systemName: isLoading ? "hourglass" : "paperplane.fill") 33 | .foregroundColor(.white) 34 | .font(.system(size: 12)) 35 | .frame(width: 24, height: 24) 36 | .background(Color.blue) 37 | .clipShape(Circle()) 38 | .scaleEffect(isHovered ? 1.05 : 1.0) 39 | .opacity(isHovered ? 1.0 : 0.9) 40 | } 41 | .buttonStyle(.plain) 42 | .padding(.trailing, 8) 43 | .transition(.opacity) 44 | .onHover { hovering in 45 | isHovered = hovering 46 | } 47 | .help("Send message") 48 | .accessibilityLabel("Send message") 49 | } 50 | } 51 | .frame(height: 36) 52 | .background( 53 | ZStack { 54 | if colorScheme == .dark { 55 | Color.black.opacity(0.2) 56 | .blur(radius: 0.5) 57 | } else { 58 | Color(.textBackgroundColor) 59 | } 60 | 61 | if isLoading { 62 | Color.gray.opacity(0.1) 63 | } 64 | } 65 | ) 66 | .cornerRadius(6) 67 | .overlay( 68 | RoundedRectangle(cornerRadius: 6) 69 | .strokeBorder( 70 | isAnimating 71 | ? Color.blue.opacity(0.8) 72 | : Color.gray.opacity(0.2), 73 | lineWidth: isAnimating ? 2 : 0.5 74 | ) 75 | .animation(.easeInOut(duration: animationDuration), value: isAnimating) 76 | ) 77 | } 78 | 79 | private func performSubmitAnimation() { 80 | withAnimation(.easeInOut(duration: animationDuration)) { 81 | isAnimating = true 82 | } 83 | 84 | onSubmit() 85 | 86 | Task { @MainActor in 87 | try? await Task.sleep(nanoseconds: animationNanoseconds) 88 | withAnimation(.easeInOut(duration: animationDuration)) { 89 | isAnimating = false 90 | } 91 | } 92 | } 93 | } 94 | 95 | extension View { 96 | func appleStyleTextField( 97 | text: String, 98 | isLoading: Bool = false, 99 | onSubmit: @escaping () -> Void 100 | ) -> some View { 101 | self.modifier(AppleStyleTextFieldModifier(isLoading: isLoading, text: text, onSubmit: onSubmit)) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /macOS/WritingTools/Services/ClipboardSnapshot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClipboardSnapshot.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 17.11.25. 6 | // 7 | 8 | import AppKit 9 | 10 | /// A comprehensive snapshot of the clipboard state that captures all items and all types 11 | struct ClipboardSnapshot { 12 | /// All pasteboard items with their data 13 | private let items: [[NSPasteboard.PasteboardType: Data]] 14 | 15 | /// The change count at the time of snapshot 16 | let changeCount: Int 17 | 18 | /// Creates a snapshot of the current clipboard state 19 | init() { 20 | let pb = NSPasteboard.general 21 | self.changeCount = pb.changeCount 22 | 23 | var capturedItems: [[NSPasteboard.PasteboardType: Data]] = [] 24 | 25 | // Capture all items on the pasteboard 26 | if let pasteboardItems = pb.pasteboardItems { 27 | for item in pasteboardItems { 28 | var itemData: [NSPasteboard.PasteboardType: Data] = [:] 29 | 30 | // Get all types available for this item 31 | for type in item.types { 32 | // Try to get data for each type 33 | if let data = item.data(forType: type) { 34 | itemData[type] = data 35 | } 36 | } 37 | 38 | if !itemData.isEmpty { 39 | capturedItems.append(itemData) 40 | } 41 | } 42 | } 43 | 44 | self.items = capturedItems 45 | 46 | NSLog("ClipboardSnapshot: Captured \(capturedItems.count) items with total types: \(capturedItems.flatMap { $0.keys }.count)") 47 | } 48 | 49 | /// Restores this snapshot to the clipboard 50 | func restore() { 51 | let pb = NSPasteboard.general 52 | pb.clearContents() 53 | 54 | guard !items.isEmpty else { 55 | NSLog("ClipboardSnapshot: No items to restore") 56 | return 57 | } 58 | 59 | // Prepare pasteboard items 60 | var pasteboardItems: [NSPasteboardItem] = [] 61 | 62 | for itemData in items { 63 | let pasteboardItem = NSPasteboardItem() 64 | 65 | // Set data for each type 66 | for (type, data) in itemData { 67 | pasteboardItem.setData(data, forType: type) 68 | } 69 | 70 | pasteboardItems.append(pasteboardItem) 71 | } 72 | 73 | // Write all items to the pasteboard 74 | let success = pb.writeObjects(pasteboardItems) 75 | 76 | if success { 77 | NSLog("ClipboardSnapshot: Successfully restored \(pasteboardItems.count) items") 78 | } else { 79 | NSLog("ClipboardSnapshot: Failed to restore clipboard items") 80 | } 81 | } 82 | 83 | /// Returns true if this snapshot contains any data 84 | var isEmpty: Bool { 85 | return items.isEmpty 86 | } 87 | 88 | /// Returns the number of items in this snapshot 89 | var itemCount: Int { 90 | return items.count 91 | } 92 | 93 | /// Returns a debug description of the snapshot 94 | var debugDescription: String { 95 | var description = "ClipboardSnapshot: \(items.count) items\n" 96 | for (index, item) in items.enumerated() { 97 | description += " Item \(index): \(item.keys.map { $0.rawValue }.joined(separator: ", "))\n" 98 | } 99 | return description 100 | } 101 | } 102 | 103 | extension NSPasteboard { 104 | /// Convenience method to create and return a snapshot 105 | func createSnapshot() -> ClipboardSnapshot { 106 | return ClipboardSnapshot() 107 | } 108 | 109 | /// Convenience method to restore a snapshot 110 | func restore(snapshot: ClipboardSnapshot) { 111 | snapshot.restore() 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Windows_and_Linux/ui/UIUtils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from PySide6 import QtGui, QtCore, QtWidgets 5 | from PySide6.QtGui import QImage, QPixmap 6 | 7 | import darkdetect 8 | colorMode = 'dark' if darkdetect.isDark() else 'light' 9 | 10 | class UIUtils: 11 | @classmethod 12 | def clear_layout(cls, layout): 13 | """ 14 | Clear the layout of all widgets. 15 | """ 16 | while ((child := layout.takeAt(0)) != None): 17 | #If the child is a layout, delete it 18 | if child.layout(): 19 | cls.clear_layout(child.layout()) 20 | child.layout().deleteLater() 21 | else: 22 | child.widget().deleteLater() 23 | 24 | @classmethod 25 | def resize_and_round_image(cls, image, image_size = 100, rounding_amount = 50): 26 | image = image.scaledToWidth(image_size) 27 | clipPath = QtGui.QPainterPath() 28 | clipPath.addRoundedRect(0, 0, image_size, image_size, rounding_amount, rounding_amount) 29 | target = QImage(image_size, image_size, QImage.Format_ARGB32) 30 | target.fill(QtCore.Qt.GlobalColor.transparent) 31 | painter = QtGui.QPainter(target) 32 | painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) 33 | painter.setClipPath(clipPath) 34 | painter.drawImage(0, 0, image) 35 | painter.end() 36 | targetPixmap = QPixmap.fromImage(target) 37 | return targetPixmap 38 | 39 | @classmethod 40 | def setup_window_and_layout(cls, base: QtWidgets.QWidget): 41 | # Set the window icon 42 | icon_path = os.path.join(os.path.dirname(sys.argv[0]), 'icons', 'app_icon.png') 43 | if os.path.exists(icon_path): base.setWindowIcon(QtGui.QIcon(icon_path)) 44 | main_layout = QtWidgets.QVBoxLayout(base) 45 | main_layout.setContentsMargins(0, 0, 0, 0) 46 | base.background = ThemeBackground(base, 'gradient') 47 | main_layout.addWidget(base.background) 48 | 49 | 50 | class ThemeBackground(QtWidgets.QWidget): 51 | """ 52 | A custom widget that creates a background for the application based on the selected theme. 53 | """ 54 | def __init__(self, parent=None, theme='gradient', is_popup=False, border_radius=0): 55 | super().__init__(parent) 56 | self.setAttribute(QtCore.Qt.WA_StyledBackground, True) 57 | self.theme = theme 58 | self.is_popup = is_popup 59 | self.border_radius = border_radius 60 | 61 | def paintEvent(self, event): 62 | """ 63 | Override the paint event to draw the background based on the selected theme. 64 | """ 65 | painter = QtGui.QPainter(self) 66 | painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) 67 | painter.setRenderHint(QtGui.QPainter.RenderHint.SmoothPixmapTransform, True) 68 | if self.theme == 'gradient': 69 | if self.is_popup: 70 | background_image = QtGui.QPixmap(os.path.join(os.path.dirname(sys.argv[0]), 'background_popup_dark.png' if colorMode == 'dark' else 'background_popup.png')) 71 | else: 72 | background_image = QtGui.QPixmap(os.path.join(os.path.dirname(sys.argv[0]), 'background_dark.png' if colorMode == 'dark' else 'background.png')) 73 | # Adds a path/border using which the border radius would be drawn 74 | path = QtGui.QPainterPath() 75 | path.addRoundedRect(0, 0, self.width(), self.height(), self.border_radius, self.border_radius) 76 | painter.setClipPath(path) 77 | 78 | painter.drawPixmap(self.rect(), background_image) 79 | else: 80 | if colorMode == 'dark': 81 | color = QtGui.QColor(35, 35, 35) # Dark mode color 82 | else: 83 | color = QtGui.QColor(222, 222, 222) # Light mode color 84 | brush = QtGui.QBrush(color) 85 | painter.setBrush(brush) 86 | pen = QtGui.QPen(QtGui.QColor(0, 0, 0, 0)) 87 | pen.setWidth(0) 88 | painter.setPen(pen) 89 | painter.drawRoundedRect(QtCore.QRect(0, 0, self.width(), self.height()), self.border_radius, self.border_radius) 90 | -------------------------------------------------------------------------------- /macOS/WritingTools/App/KeychainMigrationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Untitled.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import Foundation 9 | import Security 10 | 11 | class KeychainMigrationManager { 12 | static let shared = KeychainMigrationManager() 13 | 14 | private let keychain = KeychainManager.shared 15 | private let userDefaults = UserDefaults.standard 16 | 17 | // Migration tracking 18 | private let migrationCompleteKey = "keychain_migration_complete_v1" 19 | private let migrationLogKey = "keychain_migration_log" 20 | 21 | private init() {} 22 | 23 | // MARK: - Public API 24 | 25 | func migrateIfNeeded() { 26 | // Skip if already migrated 27 | guard !hasMigrationCompleted() else { 28 | NSLog("Keychain migration already completed") 29 | return 30 | } 31 | 32 | NSLog("Starting Keychain migration for API keys...") 33 | 34 | let keysToMigrate = [ 35 | ("gemini_api_key", "gemini_api_key"), 36 | ("openai_api_key", "openai_api_key"), 37 | ("mistral_api_key", "mistral_api_key"), 38 | ("anthropic_api_key", "anthropic_api_key"), 39 | ("openrouter_api_key", "openrouter_api_key"), 40 | ] 41 | 42 | var migratedKeys: [String] = [] 43 | var failedKeys: [String] = [] 44 | 45 | for (oldKey, newKey) in keysToMigrate { 46 | if let value = userDefaults.string(forKey: oldKey), !value.isEmpty { 47 | do { 48 | try keychain.save(value, forKey: newKey) 49 | migratedKeys.append(oldKey) 50 | NSLog("✓ Migrated: \(oldKey)") 51 | 52 | // Remove from UserDefaults after successful migration 53 | userDefaults.removeObject(forKey: oldKey) 54 | } catch { 55 | failedKeys.append(oldKey) 56 | NSLog("✗ Failed to migrate \(oldKey): \(error.localizedDescription)") 57 | } 58 | } 59 | } 60 | 61 | // Log migration results 62 | logMigration(migratedKeys: migratedKeys, failedKeys: failedKeys) 63 | 64 | // Mark migration as complete 65 | markMigrationComplete() 66 | 67 | NSLog("Keychain migration complete. Migrated: \(migratedKeys.count), Failed: \(failedKeys.count)") 68 | } 69 | 70 | // MARK: - Private Methods 71 | 72 | private func hasMigrationCompleted() -> Bool { 73 | return userDefaults.bool(forKey: migrationCompleteKey) 74 | } 75 | 76 | private func markMigrationComplete() { 77 | userDefaults.set(true, forKey: migrationCompleteKey) 78 | userDefaults.synchronize() 79 | } 80 | 81 | private func logMigration(migratedKeys: [String], failedKeys: [String]) { 82 | let timestamp = ISO8601DateFormatter().string(from: Date()) 83 | let logEntry = """ 84 | [Migration Log - \(timestamp)] 85 | Migrated keys: \(migratedKeys.isEmpty ? "none" : migratedKeys.joined(separator: ", ")) 86 | Failed keys: \(failedKeys.isEmpty ? "none" : failedKeys.joined(separator: ", ")) 87 | """ 88 | 89 | var existingLog = userDefaults.string(forKey: migrationLogKey) ?? "" 90 | existingLog += "\n" + logEntry 91 | userDefaults.set(existingLog, forKey: migrationLogKey) 92 | } 93 | 94 | // MARK: - Debug/Admin Methods 95 | 96 | func getMigrationLog() -> String { 97 | return userDefaults.string(forKey: migrationLogKey) ?? "No migration log available" 98 | } 99 | 100 | func resetMigration() { 101 | userDefaults.removeObject(forKey: migrationCompleteKey) 102 | userDefaults.removeObject(forKey: migrationLogKey) 103 | userDefaults.synchronize() 104 | NSLog("Migration reset flag cleared") 105 | } 106 | 107 | func forceMigration() { 108 | resetMigration() 109 | migrateIfNeeded() 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Windows_and_Linux/ui/AutostartManager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | if sys.platform.startswith("win32"): 5 | import winreg 6 | 7 | class AutostartManager: 8 | """ 9 | Manages the autostart functionality for Writing Tools. 10 | Handles setting/removing autostart registry entries on Windows. 11 | """ 12 | 13 | @staticmethod 14 | def is_compiled(): 15 | """ 16 | Check if we're running from a compiled exe or source. 17 | """ 18 | return hasattr(sys, 'frozen') and hasattr(sys, '_MEIPASS') 19 | 20 | @staticmethod 21 | def get_startup_path(): 22 | """ 23 | Get the path that should be used for autostart. 24 | Returns None if running from source or on non-Windows. 25 | """ 26 | if not sys.platform.startswith('win32'): 27 | return None 28 | 29 | if not AutostartManager.is_compiled(): 30 | return None 31 | 32 | return sys.executable 33 | 34 | @staticmethod 35 | def set_autostart(enable: bool) -> bool: 36 | """ 37 | Enable or disable autostart for Writing Tools. 38 | 39 | Args: 40 | enable: True to enable autostart, False to disable 41 | 42 | Returns: 43 | bool: True if operation succeeded, False if failed or unsupported 44 | """ 45 | try: 46 | startup_path = AutostartManager.get_startup_path() 47 | if not startup_path: 48 | return False 49 | 50 | key_path = r"Software\Microsoft\Windows\CurrentVersion\Run" 51 | 52 | try: 53 | if enable: 54 | # Open/create key and set value 55 | key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, 56 | winreg.KEY_WRITE) 57 | winreg.SetValueEx(key, "WritingTools", 0, winreg.REG_SZ, 58 | startup_path) 59 | else: 60 | # Open key and delete value if it exists 61 | key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, 62 | winreg.KEY_WRITE) 63 | try: 64 | winreg.DeleteValue(key, "WritingTools") 65 | except WindowsError: 66 | # Value doesn't exist, that's fine 67 | pass 68 | 69 | winreg.CloseKey(key) 70 | return True 71 | 72 | except WindowsError as e: 73 | logging.error(f"Failed to modify autostart registry: {e}") 74 | return False 75 | 76 | except Exception as e: 77 | logging.error(f"Error managing autostart: {e}") 78 | return False 79 | 80 | @staticmethod 81 | def check_autostart() -> bool: 82 | """ 83 | Check if Writing Tools is set to start automatically. 84 | 85 | Returns: 86 | bool: True if autostart is enabled, False if disabled or unsupported 87 | """ 88 | try: 89 | startup_path = AutostartManager.get_startup_path() 90 | if not startup_path: 91 | return False 92 | 93 | try: 94 | key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 95 | r"Software\Microsoft\Windows\CurrentVersion\Run", 96 | 0, winreg.KEY_READ) 97 | value, _ = winreg.QueryValueEx(key, "WritingTools") 98 | winreg.CloseKey(key) 99 | 100 | # Check if the stored path matches our current exe 101 | return value.lower() == startup_path.lower() 102 | 103 | except WindowsError: 104 | # Key or value doesn't exist 105 | return False 106 | 107 | except Exception as e: 108 | logging.error(f"Error checking autostart status: {e}") 109 | return False -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Commands/CommandButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CommandButton: View { 4 | let command: CommandModel 5 | let isEditing: Bool 6 | let isLoading: Bool 7 | let onTap: () -> Void 8 | let onEdit: () -> Void 9 | let onDelete: () -> Void 10 | 11 | var body: some View { 12 | ZStack { 13 | // Main button wrapper 14 | Button(action: { 15 | if !isEditing && !isLoading { 16 | onTap() 17 | } 18 | }) { 19 | HStack { 20 | // Leave space for the delete button if in edit mode 21 | if isEditing { 22 | Color.clear 23 | .frame(width: 10, height: 16) 24 | } 25 | 26 | HStack(spacing: 4) { 27 | Image(systemName: command.icon) 28 | Text(command.name) 29 | .lineLimit(1) 30 | .truncationMode(.tail) 31 | } 32 | 33 | // Leave space for the edit button if in edit mode 34 | if isEditing { 35 | Color.clear 36 | .frame(width: 10, height: 16) 37 | } 38 | } 39 | .frame(maxWidth: 140) 40 | .padding() 41 | .background { 42 | if #available(macOS 26, *) { 43 | // Use Liquid Glass effect on macOS 26+ 44 | Color.clear 45 | .glassEffect(.regular, in: .rect(cornerRadius: 8)) 46 | } else { 47 | // Fallback for older macOS versions 48 | Color(.controlBackgroundColor) 49 | .cornerRadius(8) 50 | } 51 | } 52 | } 53 | .buttonStyle(LoadingButtonStyle(isLoading: isLoading)) 54 | .disabled(isLoading || isEditing) 55 | 56 | // Overlay edit controls when in edit mode 57 | if isEditing { 58 | HStack { 59 | Button(action: onDelete) { 60 | Image(systemName: "minus.circle") 61 | .foregroundColor(.red) 62 | .padding(8) 63 | .contentShape(Rectangle()) 64 | } 65 | .buttonStyle(.plain) 66 | 67 | Spacer() 68 | 69 | Button(action: onEdit) { 70 | Image(systemName: "pencil.circle") 71 | .foregroundColor(.blue) 72 | .padding(8) 73 | .contentShape(Rectangle()) 74 | } 75 | .buttonStyle(.plain) 76 | } 77 | .frame(maxWidth: 140) 78 | .padding(.horizontal, 8) 79 | } 80 | } 81 | } 82 | } 83 | 84 | struct LoadingButtonStyle: ButtonStyle { 85 | var isLoading: Bool 86 | 87 | func makeBody(configuration: Configuration) -> some View { 88 | configuration.label 89 | .opacity(isLoading ? 0.5 : 1.0) 90 | .overlay( 91 | Group { 92 | if isLoading { 93 | ProgressView() 94 | .progressViewStyle(CircularProgressViewStyle()) 95 | } 96 | } 97 | ) 98 | } 99 | } 100 | 101 | #Preview { 102 | VStack { 103 | CommandButton( 104 | command: CommandModel.proofread, 105 | isEditing: false, 106 | isLoading: false, 107 | onTap: {}, 108 | onEdit: {}, 109 | onDelete: {} 110 | ) 111 | 112 | CommandButton( 113 | command: CommandModel.proofread, 114 | isEditing: true, 115 | isLoading: false, 116 | onTap: {}, 117 | onEdit: {}, 118 | onDelete: {} 119 | ) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Windows_and_Linux/pyinstaller-build-script.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | 5 | 6 | def run_pyinstaller_build(): 7 | pyinstaller_command = [ 8 | "pyinstaller", 9 | "--onefile", 10 | "--windowed", 11 | "--icon=icons/app_icon.ico", 12 | "--name=Writing Tools", 13 | "--clean", 14 | "--noconfirm", 15 | # Exclude unnecessary modules 16 | "--exclude-module", "tkinter", 17 | "--exclude-module", "unittest", 18 | "--exclude-module", "IPython", 19 | "--exclude-module", "jedi", 20 | "--exclude-module", "email_validator", 21 | "--exclude-module", "cryptography", 22 | "--exclude-module", "psutil", 23 | "--exclude-module", "pyzmq", 24 | "--exclude-module", "tornado", 25 | # Exclude modules related to PySide6 that are not used 26 | "--exclude-module", "PySide6.QtNetwork", 27 | "--exclude-module", "PySide6.QtXml", 28 | "--exclude-module", "PySide6.QtQml", 29 | "--exclude-module", "PySide6.QtQuick", 30 | "--exclude-module", "PySide6.QtQuickWidgets", 31 | "--exclude-module", "PySide6.QtPrintSupport", 32 | "--exclude-module", "PySide6.QtSql", 33 | "--exclude-module", "PySide6.QtTest", 34 | "--exclude-module", "PySide6.QtSvg", 35 | "--exclude-module", "PySide6.QtSvgWidgets", 36 | "--exclude-module", "PySide6.QtHelp", 37 | "--exclude-module", "PySide6.QtMultimedia", 38 | "--exclude-module", "PySide6.QtMultimediaWidgets", 39 | "--exclude-module", "PySide6.QtOpenGL", 40 | "--exclude-module", "PySide6.QtOpenGLWidgets", 41 | "--exclude-module", "PySide6.QtPositioning", 42 | "--exclude-module", "PySide6.QtLocation", 43 | "--exclude-module", "PySide6.QtSerialPort", 44 | "--exclude-module", "PySide6.QtWebChannel", 45 | "--exclude-module", "PySide6.QtWebSockets", 46 | "--exclude-module", "PySide6.QtWinExtras", 47 | "--exclude-module", "PySide6.QtNetworkAuth", 48 | "--exclude-module", "PySide6.QtRemoteObjects", 49 | "--exclude-module", "PySide6.QtTextToSpeech", 50 | "--exclude-module", "PySide6.QtWebEngineCore", 51 | "--exclude-module", "PySide6.QtWebEngineWidgets", 52 | "--exclude-module", "PySide6.QtWebEngine", 53 | "--exclude-module", "PySide6.QtBluetooth", 54 | "--exclude-module", "PySide6.QtNfc", 55 | "--exclude-module", "PySide6.QtWebView", 56 | "--exclude-module", "PySide6.QtCharts", 57 | "--exclude-module", "PySide6.QtDataVisualization", 58 | "--exclude-module", "PySide6.QtPdf", 59 | "--exclude-module", "PySide6.QtPdfWidgets", 60 | "--exclude-module", "PySide6.QtQuick3D", 61 | "--exclude-module", "PySide6.QtQuickControls2", 62 | "--exclude-module", "PySide6.QtQuickParticles", 63 | "--exclude-module", "PySide6.QtQuickTest", 64 | "--exclude-module", "PySide6.QtQuickWidgets", 65 | "--exclude-module", "PySide6.QtSensors", 66 | "--exclude-module", "PySide6.QtStateMachine", 67 | "--exclude-module", "PySide6.Qt3DCore", 68 | "--exclude-module", "PySide6.Qt3DRender", 69 | "--exclude-module", "PySide6.Qt3DInput", 70 | "--exclude-module", "PySide6.Qt3DLogic", 71 | "--exclude-module", "PySide6.Qt3DAnimation", 72 | "--exclude-module", "PySide6.Qt3DExtras", 73 | "main.py" 74 | ] 75 | 76 | try: 77 | # Remove previous build directories 78 | if os.path.exists('dist'): 79 | os.system("rmdir /s /q dist") 80 | if os.path.exists('build'): 81 | os.system("rmdir /s /q build") 82 | if os.path.exists('__pycache__'): 83 | os.system("rmdir /s /q __pycache__") 84 | 85 | # Run PyInstaller 86 | subprocess.run(pyinstaller_command, check=True) 87 | print("Build completed successfully!") 88 | 89 | # Clean up unnecessary files 90 | if os.path.exists('build'): 91 | os.system("rmdir /s /q build") 92 | if os.path.exists('__pycache__'): 93 | os.system("rmdir /s /q __pycache__") 94 | 95 | # No need to copy data files manually since they are included 96 | # in the executable using --add-data 97 | 98 | except subprocess.CalledProcessError as e: 99 | print(f"Build failed with error: {e}") 100 | sys.exit(1) 101 | 102 | if __name__ == "__main__": 103 | run_pyinstaller_build() -------------------------------------------------------------------------------- /macOS/WritingTools/Models/AI Providers/AnthropicProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AIProxy 3 | 4 | struct AnthropicConfig: Codable { 5 | var apiKey: String 6 | var model: String 7 | 8 | static let defaultModel = "claude-3-5-sonnet-20240620" 9 | } 10 | 11 | enum AnthropicModel: String, CaseIterable { 12 | case claude45Haiku = "claude-haiku-4-5" 13 | case claude45Sonnet = "claude-sonnet-4-5" 14 | case claude41Opus = "claude-opus-4-1" 15 | case custom 16 | 17 | var displayName: String { 18 | switch self { 19 | case .claude45Haiku: return "Claude 4.5 Haiku (Fastest, Most Affordable)" 20 | case .claude45Sonnet: return "Claude 4.5 Sonnet (Best Coding Model)" 21 | case .claude41Opus: return "Claude 4.1 Opus (Most Capable, Expensive)" 22 | case .custom: return "Custom" 23 | } 24 | } 25 | } 26 | 27 | @MainActor 28 | class AnthropicProvider: ObservableObject, AIProvider { 29 | @Published var isProcessing = false 30 | 31 | private var config: AnthropicConfig 32 | private var aiProxyService: AnthropicService? 33 | private var currentTask: Task? 34 | 35 | init(config: AnthropicConfig) { 36 | self.config = config 37 | setupAIProxyService() 38 | } 39 | 40 | private func setupAIProxyService() { 41 | guard !config.apiKey.isEmpty else { return } 42 | aiProxyService = AIProxy.anthropicDirectService(unprotectedAPIKey: config.apiKey) 43 | } 44 | 45 | func processText( 46 | systemPrompt: String? = "You are a helpful writing assistant.", 47 | userPrompt: String, 48 | images: [Data] = [], 49 | streaming: Bool = false 50 | ) async throws -> String { 51 | isProcessing = true 52 | defer { isProcessing = false } 53 | 54 | guard !config.apiKey.isEmpty else { 55 | throw NSError( 56 | domain: "AnthropicAPI", 57 | code: -1, 58 | userInfo: [NSLocalizedDescriptionKey: "API key is missing."] 59 | ) 60 | } 61 | 62 | if aiProxyService == nil { 63 | setupAIProxyService() 64 | } 65 | 66 | guard let anthropicService = aiProxyService else { 67 | throw NSError( 68 | domain: "AnthropicAPI", 69 | code: -1, 70 | userInfo: [NSLocalizedDescriptionKey: "Failed to initialize AIProxy service."] 71 | ) 72 | } 73 | 74 | // Compose messages array 75 | var messages: [AnthropicInputMessage] = [] 76 | 77 | var userContent: [AnthropicInputContent] = [.text(userPrompt)] 78 | for imageData in images { 79 | userContent.append( 80 | .image(mediaType: AnthropicImageMediaType.jpeg, data: imageData.base64EncodedString()) 81 | ) 82 | } 83 | messages.append( 84 | AnthropicInputMessage(content: userContent, role: .user) 85 | ) 86 | 87 | let requestBody = AnthropicMessageRequestBody( 88 | maxTokens: 1024, 89 | messages: messages, 90 | model: config.model.isEmpty ? AnthropicConfig.defaultModel : config.model, 91 | system: systemPrompt 92 | ) 93 | 94 | do { 95 | let response = try await anthropicService.messageRequest(body: requestBody) 96 | 97 | for content in response.content { 98 | switch content { 99 | case .text(let message): 100 | return message 101 | case .toolUse(id: _, name: let toolName, input: let toolInput): 102 | print("Anthropic tool use: \(toolName) input: \(toolInput)") 103 | } 104 | } 105 | throw NSError( 106 | domain: "AnthropicAPI", 107 | code: -1, 108 | userInfo: [NSLocalizedDescriptionKey: "No text content in response."] 109 | ) 110 | } catch AIProxyError.unsuccessfulRequest(let statusCode, let responseBody) { 111 | print("Anthropic error (\(statusCode)): \(responseBody)") 112 | throw NSError( 113 | domain: "AnthropicAPI", 114 | code: statusCode, 115 | userInfo: [NSLocalizedDescriptionKey: "API error: \(responseBody)"] 116 | ) 117 | } catch { 118 | print("Anthropic request failed: \(error.localizedDescription)") 119 | throw error 120 | } 121 | } 122 | 123 | func cancel() { 124 | currentTask?.cancel() 125 | currentTask = nil 126 | isProcessing = false 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /macOS/WritingTools/App/KeychainManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeychainManager.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import Foundation 9 | import Security 10 | 11 | class KeychainManager { 12 | static let shared = KeychainManager() 13 | 14 | private init() {} 15 | 16 | enum KeychainError: LocalizedError { 17 | case failedToSave(OSStatus) 18 | case failedToRead(OSStatus) 19 | case failedToDelete(OSStatus) 20 | case noDataFound 21 | 22 | var errorDescription: String? { 23 | switch self { 24 | case .failedToSave(let status): 25 | return "Failed to save to Keychain: \(status)" 26 | case .failedToRead(let status): 27 | return "Failed to read from Keychain: \(status)" 28 | case .failedToDelete(let status): 29 | return "Failed to delete from Keychain: \(status)" 30 | case .noDataFound: 31 | return "No data found in Keychain" 32 | } 33 | } 34 | } 35 | 36 | // MARK: - Save 37 | 38 | func save(_ value: String, forKey key: String) throws { 39 | guard !value.isEmpty else { 40 | try delete(forKey: key) 41 | return 42 | } 43 | 44 | guard let data = value.data(using: .utf8) else { 45 | throw KeychainError.failedToSave(-1) 46 | } 47 | 48 | let query: [String: Any] = [ 49 | kSecClass as String: kSecClassGenericPassword, 50 | kSecAttrAccount as String: key, 51 | kSecAttrService as String: "com.aryamirsepasi.writing-tools", 52 | kSecValueData as String: data, 53 | kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked 54 | ] 55 | 56 | // Try to delete existing first 57 | SecItemDelete(query as CFDictionary) 58 | 59 | let status = SecItemAdd(query as CFDictionary, nil) 60 | guard status == errSecSuccess else { 61 | throw KeychainError.failedToSave(status) 62 | } 63 | } 64 | 65 | // MARK: - Read 66 | 67 | func retrieve(forKey key: String) throws -> String? { 68 | let query: [String: Any] = [ 69 | kSecClass as String: kSecClassGenericPassword, 70 | kSecAttrAccount as String: key, 71 | kSecAttrService as String: "com.aryamirsepasi.writing-tools", 72 | kSecReturnData as String: true, 73 | kSecMatchLimit as String: kSecMatchLimitOne 74 | ] 75 | 76 | var result: AnyObject? 77 | let status = SecItemCopyMatching(query as CFDictionary, &result) 78 | 79 | if status == errSecItemNotFound { 80 | return nil 81 | } 82 | 83 | guard status == errSecSuccess else { 84 | throw KeychainError.failedToRead(status) 85 | } 86 | 87 | guard let data = result as? Data else { 88 | throw KeychainError.noDataFound 89 | } 90 | 91 | return String(data: data, encoding: .utf8) 92 | } 93 | 94 | // MARK: - Delete 95 | 96 | func delete(forKey key: String) throws { 97 | let query: [String: Any] = [ 98 | kSecClass as String: kSecClassGenericPassword, 99 | kSecAttrAccount as String: key, 100 | kSecAttrService as String: "com.aryamirsepasi.writing-tools" 101 | ] 102 | 103 | let status = SecItemDelete(query as CFDictionary) 104 | guard status == errSecSuccess || status == errSecItemNotFound else { 105 | throw KeychainError.failedToDelete(status) 106 | } 107 | } 108 | 109 | // MARK: - Clear All 110 | 111 | func clearAllApiKeys() throws { 112 | let apiKeyNames = [ 113 | "gemini_api_key", 114 | "openai_api_key", 115 | "mistral_api_key", 116 | "anthropic_api_key", 117 | "openrouter_api_key" 118 | ] 119 | 120 | for keyName in apiKeyNames { 121 | try? delete(forKey: keyName) 122 | } 123 | } 124 | 125 | func hasMigratedKey(forKey key: String) -> Bool { 126 | do { 127 | let value = try retrieve(forKey: key) 128 | return value != nil 129 | } catch { 130 | return false 131 | } 132 | } 133 | 134 | func verifyMigration() -> [String: Bool] { 135 | let keysToCheck = [ 136 | "gemini_api_key", 137 | "openai_api_key", 138 | "mistral_api_key", 139 | "anthropic_api_key", 140 | "openrouter_api_key" 141 | ] 142 | 143 | var results: [String: Bool] = [:] 144 | for key in keysToCheck { 145 | results[key] = hasMigratedKey(forKey: key) 146 | } 147 | return results 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /macOS/WritingTools/Views/About/AboutView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AboutView: View { 4 | @ObservedObject private var settings = AppSettings.shared 5 | @State private var updateChecker = UpdateChecker.shared 6 | 7 | var body: some View { 8 | VStack(spacing: 12) { 9 | // Header 10 | VStack(spacing: 6) { 11 | Text("About Writing Tools") 12 | .font(.largeTitle) 13 | .bold() 14 | .multilineTextAlignment(.center) 15 | .accessibilityAddTraits(.isHeader) 16 | 17 | Text("Writing Tools is a free, lightweight utility that enhances your writing with AI.") 18 | .multilineTextAlignment(.center) 19 | .foregroundColor(.secondary) 20 | .font(.title3) 21 | .padding(.horizontal) 22 | } 23 | .padding(.top, 8) 24 | 25 | Divider() 26 | 27 | // Authors 28 | GroupBox("Creators") { 29 | VStack(spacing: 8) { 30 | VStack(spacing: 2) { 31 | Text("Created with care by Jesai, a high school student.") 32 | .bold() 33 | HStack(spacing: 12) { 34 | Link("Email Jesai", destination: URL(string: "mailto:jesaitarun@gmail.com")!) 35 | Link("Bliss AI on Google Play", destination: URL(string: "https://play.google.com/store/apps/details?id=com.jesai.blissai")!) 36 | } 37 | } 38 | 39 | Divider() 40 | 41 | VStack(spacing: 2) { 42 | Text("macOS version by Arya Mirsepasi") 43 | .bold() 44 | HStack(spacing: 12) { 45 | Link("Email Arya", destination: URL(string: "mailto:developer@aryamirsepasi.com")!) 46 | Link("ProseKey AI (iOS port)", destination: URL(string: "https://apps.apple.com/us/app/prosekey-ai/id6741180175")!) 47 | } 48 | } 49 | } 50 | .frame(maxWidth: .infinity) 51 | } 52 | 53 | // Version and updates 54 | GroupBox("Version & Updates") { 55 | VStack(spacing: 8) { 56 | Text("Version: 5.5 (Based on Windows Port version 8.0)") 57 | .font(.caption) 58 | .frame(maxWidth: .infinity, alignment: .leading) 59 | 60 | if updateChecker.isCheckingForUpdates { 61 | ProgressView("Checking for updates...") 62 | .frame(maxWidth: .infinity, alignment: .leading) 63 | } else if let error = updateChecker.checkError { 64 | Text(error) 65 | .foregroundColor(.red) 66 | .font(.caption) 67 | .frame(maxWidth: .infinity, alignment: .leading) 68 | } else if updateChecker.updateAvailable { 69 | Text("A new version is available!") 70 | .foregroundColor(.green) 71 | .font(.caption) 72 | .frame(maxWidth: .infinity, alignment: .leading) 73 | } else { 74 | Text("The latest version is already installed!") 75 | .foregroundColor(.green) 76 | .font(.caption) 77 | .frame(maxWidth: .infinity, alignment: .leading) 78 | } 79 | 80 | HStack(spacing: 12) { 81 | Button(action: { 82 | if updateChecker.updateAvailable { 83 | updateChecker.openReleasesPage() 84 | } else { 85 | Task { await updateChecker.checkForUpdates() } 86 | } 87 | }) { 88 | Text(updateChecker.updateAvailable ? "Download Update" : "Check for Updates") 89 | } 90 | .buttonStyle(.borderedProminent) 91 | 92 | Link("View Releases", destination: URL(string: "https://github.com/theJayTea/WritingTools/releases")!) 93 | .buttonStyle(.link) 94 | } 95 | .frame(maxWidth: .infinity, alignment: .leading) 96 | } 97 | } 98 | 99 | Spacer() 100 | } 101 | .padding() 102 | .frame(width: 420, height: 420) 103 | .frame(minWidth: 400, minHeight: 380) 104 | .frame(maxWidth: .infinity, maxHeight: .infinity) 105 | .windowBackground(useGradient: settings.useGradientTheme) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /macOS/WritingTools/Models/AI Providers/OpenRouterProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AppKit 3 | import AIProxy 4 | 5 | struct OpenRouterConfig: Codable { 6 | var apiKey: String 7 | var model: String 8 | static let defaultModel = "openai/gpt-4o" 9 | } 10 | 11 | enum OpenRouterModel: String, CaseIterable { 12 | case gpt4o = "openai/gpt-4o" 13 | case deepseekR1 = "deepseek/deepseek-r1" 14 | case deepseekChat = "deepseek/deepseek-chat" 15 | case grok2Vision = "x-ai/grok-2-vision-1212" 16 | case custom 17 | 18 | var displayName: String { 19 | switch self { 20 | case .gpt4o: return "OpenAI GPT-4o" 21 | case .deepseekR1: return "DeepSeek R1" 22 | case .deepseekChat: return "DeepSeek Chat" 23 | case .grok2Vision: return "Grok 2 Vision" 24 | case .custom: return "Custom" 25 | } 26 | } 27 | } 28 | 29 | @MainActor 30 | class OpenRouterProvider: ObservableObject, AIProvider { 31 | @Published var isProcessing = false 32 | 33 | private var config: OpenRouterConfig 34 | private var aiProxyService: OpenRouterService? 35 | private var currentTask: Task? 36 | 37 | init(config: OpenRouterConfig) { 38 | self.config = config 39 | setupAIProxyService() 40 | } 41 | 42 | private func setupAIProxyService() { 43 | guard !config.apiKey.isEmpty else { return } 44 | aiProxyService = AIProxy.openRouterDirectService(unprotectedAPIKey: config.apiKey) 45 | } 46 | 47 | func processText( 48 | systemPrompt: String? = "You are a helpful writing assistant.", 49 | userPrompt: String, 50 | images: [Data] = [], 51 | streaming: Bool = false 52 | ) async throws -> String { 53 | isProcessing = true 54 | defer { isProcessing = false } 55 | 56 | guard !config.apiKey.isEmpty else { 57 | throw NSError( 58 | domain: "OpenRouterAPI", 59 | code: -1, 60 | userInfo: [NSLocalizedDescriptionKey: "API key is missing."] 61 | ) 62 | } 63 | 64 | if aiProxyService == nil { 65 | setupAIProxyService() 66 | } 67 | 68 | guard let openRouterService = aiProxyService else { 69 | throw NSError( 70 | domain: "OpenRouterAPI", 71 | code: -1, 72 | userInfo: [NSLocalizedDescriptionKey: "Failed to initialize AIProxy service."] 73 | ) 74 | } 75 | 76 | // Compose messages 77 | var messages: [OpenRouterChatCompletionRequestBody.Message] = [] 78 | if let systemPrompt = systemPrompt, !systemPrompt.isEmpty { 79 | messages.append(.system(content: .text(systemPrompt))) 80 | } 81 | 82 | if images.isEmpty { 83 | messages.append(.user(content: .text(userPrompt))) 84 | } else { 85 | var parts: [OpenRouterChatCompletionRequestBody.Message.UserContent.Part] = [.text(userPrompt)] 86 | for imageData in images { 87 | if let nsImage = NSImage(data: imageData), 88 | let imageURL = AIProxy.encodeImageAsURL(image: nsImage, compressionQuality: 0.8) { 89 | parts.append(.imageURL(imageURL)) 90 | } 91 | } 92 | messages.append(.user(content: .parts(parts))) 93 | } 94 | 95 | let modelName = config.model.isEmpty ? OpenRouterConfig.defaultModel : config.model 96 | 97 | let requestBody = OpenRouterChatCompletionRequestBody( 98 | messages: messages, 99 | models: [modelName], 100 | route: .fallback 101 | ) 102 | 103 | do { 104 | if streaming { 105 | var compiledResponse = "" 106 | let stream = try await openRouterService.streamingChatCompletionRequest(body: requestBody) 107 | for try await chunk in stream { 108 | if Task.isCancelled { break } 109 | if let content = chunk.choices.first?.delta.content { 110 | compiledResponse += content 111 | } 112 | } 113 | return compiledResponse 114 | } else { 115 | let response = try await openRouterService.chatCompletionRequest(body: requestBody) 116 | return response.choices.first?.message.content ?? "" 117 | } 118 | } catch AIProxyError.unsuccessfulRequest(let statusCode, let responseBody) { 119 | print("OpenRouter error (\(statusCode)): \(responseBody)") 120 | throw NSError( 121 | domain: "OpenRouterAPI", 122 | code: statusCode, 123 | userInfo: [NSLocalizedDescriptionKey: "API error: \(responseBody)"] 124 | ) 125 | } catch { 126 | print("OpenRouter request failed: \(error.localizedDescription)") 127 | throw error 128 | } 129 | } 130 | 131 | 132 | func cancel() { 133 | currentTask?.cancel() 134 | currentTask = nil 135 | isProcessing = false 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /macOS/WritingTools/Models/AI Providers/GeminiProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AIProxy 3 | 4 | struct GeminiConfig: Codable { 5 | var apiKey: String 6 | var modelName: String 7 | } 8 | 9 | enum GeminiModel: String, CaseIterable { 10 | case gemmabig = "gemma-3-27b-it" 11 | case gemmasmall = "gemma-3-4b-it" 12 | case flashlite = "gemini-flash-lite-latest" 13 | case flash = "gemini-flash-latest" 14 | case pro = "gemini-3-pro-latest" 15 | case custom = "custom" 16 | 17 | var displayName: String { 18 | switch self { 19 | case .gemmabig: return "Gemma 3 27b (Very Intelligent | unlimited)" 20 | case .gemmasmall: return "Gemma 3 4b (Intelligent | unlimited)" 21 | case .flashlite: return "Gemini Flash Lite Latest (Intelligent | ~20 uses/min)" 22 | case .flash: return "Gemini Flash Latest (Very Intelligent | ~20 uses/min)" 23 | case .pro: return "Gemini Pro latest (Peak Intelligence | ~5 uses/min)" 24 | case .custom: return "Custom" 25 | } 26 | } 27 | } 28 | 29 | @MainActor 30 | class GeminiProvider: ObservableObject, AIProvider { 31 | @Published var isProcessing = false 32 | private var config: GeminiConfig 33 | private var aiProxyService: GeminiService? 34 | private var currentTask: Task? 35 | 36 | init(config: GeminiConfig) { 37 | self.config = config 38 | setupAIProxyService() 39 | } 40 | 41 | private func setupAIProxyService() { 42 | guard !config.apiKey.isEmpty else { return } 43 | aiProxyService = AIProxy.geminiDirectService(unprotectedAPIKey: config.apiKey) 44 | } 45 | 46 | func processText(systemPrompt: String? = "You are a helpful writing assistant.", userPrompt: String, images: [Data] = [], streaming: Bool = false) async throws -> String { 47 | isProcessing = true 48 | defer { isProcessing = false } 49 | 50 | guard !config.apiKey.isEmpty else { 51 | throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "API key is missing."]) 52 | } 53 | 54 | if aiProxyService == nil { 55 | setupAIProxyService() 56 | } 57 | 58 | guard let geminiService = aiProxyService else { 59 | throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to initialize AIProxy service."]) 60 | } 61 | 62 | let finalPrompt = systemPrompt.map { "\($0)\n\n\(userPrompt)" } ?? userPrompt 63 | 64 | var parts: [GeminiGenerateContentRequestBody.Content.Part] = [.text(finalPrompt)] 65 | 66 | for imageData in images { 67 | parts.append(.inline(data: imageData, mimeType: "image/jpeg")) 68 | } 69 | 70 | let requestBody = GeminiGenerateContentRequestBody( 71 | contents: [.init(parts: parts)], 72 | safetySettings: [ 73 | .init(category: .dangerousContent, threshold: .none), 74 | .init(category: .harassment, threshold: .none), 75 | .init(category: .hateSpeech, threshold: .none), 76 | .init(category: .sexuallyExplicit, threshold: .none), 77 | .init(category: .civicIntegrity, threshold: .none) 78 | ] 79 | ) 80 | 81 | do { 82 | let response = try await geminiService.generateContentRequest(body: requestBody, model: config.modelName, secondsToWait: 60) 83 | 84 | /*if let usage = response.usageMetadata { 85 | print(""" 86 | Gemini API Usage: 87 | 88 | \(usage.promptTokenCount ?? 0) prompt tokens 89 | \(usage.candidatesTokenCount ?? 0) candidate tokens 90 | \(usage.totalTokenCount ?? 0) total tokens 91 | """) 92 | }*/ 93 | 94 | for part in response.candidates?.first?.content?.parts ?? [] { 95 | switch part { 96 | case .text(let text): 97 | return text 98 | case .functionCall(name: let functionName, args: let arguments): 99 | print("Function call received: \(functionName) with args: \(arguments ?? [:])") 100 | case .inlineData(mimeType: _, base64Data: _): 101 | print("Image generation?") 102 | } 103 | } 104 | 105 | throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "No text content in response."]) 106 | 107 | } catch AIProxyError.unsuccessfulRequest(let statusCode, let responseBody) { 108 | print("AIProxy error (\(statusCode)): \(responseBody)") 109 | throw NSError(domain: "GeminiAPI", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "API error: \(responseBody)"]) 110 | } catch { 111 | print("Gemini request failed: \(error.localizedDescription)") 112 | throw error 113 | } 114 | } 115 | 116 | func cancel() { 117 | currentTask?.cancel() 118 | currentTask = nil 119 | isProcessing = false 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /macOS/WritingTools/Services/CloudCommandsSync.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudCommandsSync.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 15.08.25. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | @MainActor 12 | final class CloudCommandsSync { 13 | static let shared = CloudCommandsSync() 14 | 15 | private let store = NSUbiquitousKeyValueStore.default 16 | 17 | // Keys for the "full command list" (edited built-ins + custom) 18 | private let dataKey = "icloud.commandManager.commands.v1.data" 19 | private let mtimeKey = "icloud.commandManager.commands.v1.mtime" 20 | private let localMTimeKey = "local.commandManager.commands.v1.mtime" 21 | 22 | private var started = false 23 | private var isApplyingCloudChange = false 24 | 25 | private var commandsChangedObserver: NSObjectProtocol? 26 | private var kvsObserver: NSObjectProtocol? 27 | private var objectWillChangeCancellable: AnyCancellable? 28 | 29 | private init() { 30 | // Start shortly after init to ensure AppState is ready 31 | DispatchQueue.main.async { [weak self] in 32 | Task { @MainActor in 33 | self?.start() 34 | } 35 | } 36 | } 37 | 38 | func start() { 39 | guard !started else { return } 40 | started = true 41 | 42 | store.synchronize() 43 | 44 | // Initial pull from iCloud if remote is newer 45 | pullFromICloudIfNewer() 46 | 47 | // Listen for your app's commands change notification 48 | commandsChangedObserver = NotificationCenter.default.addObserver( 49 | forName: NSNotification.Name("CommandsChanged"), 50 | object: nil, 51 | queue: .main 52 | ) { [weak self] _ in 53 | // Ensure we run on the MainActor 54 | Task { @MainActor in 55 | self?.pushLocalToICloud() 56 | } 57 | } 58 | 59 | // Also observe objectWillChange to catch reorders, etc. 60 | objectWillChangeCancellable = 61 | AppState.shared.commandManager.objectWillChange 62 | .debounce(for: .milliseconds(300), scheduler: RunLoop.main) 63 | .sink { [weak self] _ in 64 | Task { @MainActor in 65 | self?.pushLocalToICloud() 66 | } 67 | } 68 | 69 | // Listen for iCloud server changes 70 | kvsObserver = NotificationCenter.default.addObserver( 71 | forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, 72 | object: store, 73 | queue: .main 74 | ) { [weak self] note in 75 | // Hop to MainActor before calling a MainActor-isolated method 76 | Task { @MainActor in 77 | self?.handleICloudChange(note) 78 | } 79 | } 80 | } 81 | 82 | deinit { 83 | if let commandsChangedObserver { 84 | NotificationCenter.default.removeObserver(commandsChangedObserver) 85 | } 86 | if let kvsObserver { 87 | NotificationCenter.default.removeObserver(kvsObserver) 88 | } 89 | objectWillChangeCancellable?.cancel() 90 | } 91 | 92 | // MARK: - Push local -> iCloud 93 | 94 | private func pushLocalToICloud() { 95 | guard !isApplyingCloudChange else { return } 96 | 97 | let commands = AppState.shared.commandManager.commands 98 | 99 | do { 100 | let data = try JSONEncoder().encode(commands) 101 | let now = Date() 102 | 103 | store.set(data, forKey: dataKey) 104 | store.set(now, forKey: mtimeKey) 105 | store.synchronize() 106 | 107 | UserDefaults.standard.set(now, forKey: localMTimeKey) 108 | } catch { 109 | print("CloudCommandsSync: encode error: \(error)") 110 | } 111 | } 112 | 113 | // MARK: - Pull iCloud -> local (if newer) 114 | 115 | private func pullFromICloudIfNewer() { 116 | guard let remoteMTime = store.object(forKey: mtimeKey) as? Date else { 117 | return 118 | } 119 | let localMTime = 120 | UserDefaults.standard.object(forKey: localMTimeKey) as? Date 121 | 122 | guard localMTime == nil || remoteMTime > localMTime! else { 123 | return 124 | } 125 | 126 | guard let data = store.data(forKey: dataKey) else { return } 127 | 128 | do { 129 | let remoteCommands = try JSONDecoder().decode([CommandModel].self, from: data) 130 | 131 | isApplyingCloudChange = true 132 | AppState.shared.commandManager.replaceAllCommands(with: remoteCommands) 133 | UserDefaults.standard.set(remoteMTime, forKey: localMTimeKey) 134 | isApplyingCloudChange = false 135 | 136 | // Notify any listeners if necessary 137 | NotificationCenter.default.post( 138 | name: NSNotification.Name("CommandsChanged"), 139 | object: nil 140 | ) 141 | } catch { 142 | print("CloudCommandsSync: decode error: \(error)") 143 | } 144 | } 145 | 146 | private func handleICloudChange(_ note: Notification) { 147 | guard 148 | let userInfo = note.userInfo, 149 | let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int 150 | else { return } 151 | 152 | guard reason == NSUbiquitousKeyValueStoreServerChange 153 | || reason == NSUbiquitousKeyValueStoreInitialSyncChange 154 | else { 155 | return 156 | } 157 | 158 | if 159 | let changedKeys = 160 | userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String], 161 | changedKeys.contains(where: { $0 == dataKey || $0 == mtimeKey }) 162 | { 163 | pullFromICloudIfNewer() 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /macOS/WritingTools/Models/AI Providers/MistralProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AIProxy 3 | 4 | struct MistralConfig: Codable { 5 | var apiKey: String 6 | var baseURL: String 7 | var model: String 8 | 9 | static let defaultBaseURL = "https://api.mistral.ai/v1" 10 | static let defaultModel = "mistral-small-latest" 11 | } 12 | enum MistralModel: String, CaseIterable { 13 | case mistralSmall = "mistral-small-latest" 14 | case mistralMedium = "mistral-medium-latest" 15 | case mistralLarge = "mistral-large-latest" 16 | 17 | var displayName: String { 18 | switch self { 19 | case .mistralSmall: return "Mistral Small (Fast)" 20 | case .mistralMedium: return "Mistral Medium (Balanced)" 21 | case .mistralLarge: return "Mistral Large (Most Capable)" 22 | } 23 | } 24 | } 25 | 26 | @MainActor 27 | class MistralProvider: ObservableObject, AIProvider { 28 | @Published var isProcessing = false 29 | private var config: MistralConfig 30 | private var aiProxyService: MistralService? 31 | private var currentTask: Task? 32 | 33 | init(config: MistralConfig) { 34 | self.config = config 35 | setupAIProxyService() 36 | } 37 | 38 | private func setupAIProxyService() { 39 | guard !config.apiKey.isEmpty else { return } 40 | aiProxyService = AIProxy.mistralDirectService(unprotectedAPIKey: config.apiKey) 41 | } 42 | 43 | func processText(systemPrompt: String? = "You are a helpful writing assistant.", userPrompt: String, images: [Data] = [], streaming: Bool = false) async throws -> String { 44 | isProcessing = true 45 | defer { isProcessing = false } 46 | 47 | guard !config.apiKey.isEmpty else { 48 | throw NSError(domain: "MistralAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "API key is missing."]) 49 | } 50 | 51 | if aiProxyService == nil { 52 | setupAIProxyService() 53 | } 54 | 55 | guard let mistralService = aiProxyService else { 56 | throw NSError(domain: "MistralAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to initialize AIProxy service."]) 57 | } 58 | 59 | var messages: [MistralChatCompletionRequestBody.Message] = [] 60 | 61 | if let systemPrompt = systemPrompt { 62 | messages.append(.system(content: systemPrompt)) 63 | } 64 | 65 | // Extract OCR text from images (if any) and append to user prompt. 66 | var combinedPrompt = userPrompt 67 | if !images.isEmpty { 68 | let ocrText = await OCRManager.shared.extractText(from: images) 69 | if !ocrText.isEmpty { 70 | combinedPrompt += "\nExtracted Text: \(ocrText)" 71 | } 72 | } 73 | 74 | messages.append(.user(content: combinedPrompt)) 75 | 76 | do { 77 | if streaming { 78 | var compiledResponse = "" 79 | let stream = try await mistralService.streamingChatCompletionRequest(body: .init( 80 | messages: messages, 81 | model: config.model 82 | ), secondsToWait: 60) 83 | 84 | for try await chunk in stream { 85 | if Task.isCancelled { break } 86 | if let content = chunk.choices.first?.delta.content { 87 | compiledResponse += content 88 | } 89 | if let usage = chunk.usage { 90 | print(""" 91 | Used: 92 | \(usage.promptTokens ?? 0) prompt tokens 93 | \(usage.completionTokens ?? 0) completion tokens 94 | \(usage.totalTokens ?? 0) total tokens 95 | """) 96 | } 97 | } 98 | return compiledResponse 99 | 100 | } else { 101 | let response = try await mistralService.chatCompletionRequest(body: .init( 102 | messages: messages, 103 | model: config.model 104 | ), secondsToWait: 60) 105 | 106 | /*if let usage = response.usage { 107 | print(""" 108 | Used: 109 | \(usage.promptTokens ?? 0) prompt tokens 110 | \(usage.completionTokens ?? 0) completion tokens 111 | \(usage.totalTokens ?? 0) total tokens 112 | """) 113 | }*/ 114 | 115 | return response.choices.first?.message.content ?? "" 116 | } 117 | 118 | } catch AIProxyError.unsuccessfulRequest(let statusCode, let responseBody) { 119 | print("Received non-200 status code: \(statusCode) with response body: \(responseBody)") 120 | throw NSError(domain: "MistralAPI", 121 | code: statusCode, 122 | userInfo: [NSLocalizedDescriptionKey: "API error: \(responseBody)"]) 123 | } catch { 124 | print("Could not create mistral chat completion: \(error.localizedDescription)") 125 | throw error 126 | } 127 | } 128 | 129 | func cancel() { 130 | currentTask?.cancel() 131 | currentTask = nil 132 | isProcessing = false 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Windows_and_Linux/ui/OnboardingWindow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from PySide6 import QtCore, QtWidgets 4 | from PySide6.QtWidgets import QHBoxLayout, QRadioButton 5 | 6 | from ui.UIUtils import UIUtils, colorMode 7 | 8 | _ = lambda x: x 9 | 10 | class OnboardingWindow(QtWidgets.QWidget): 11 | # Closing signal 12 | close_signal = QtCore.Signal() 13 | 14 | def __init__(self, app): 15 | super().__init__() 16 | self.app = app 17 | self.shortcut = 'ctrl+space' 18 | self.theme = 'gradient' 19 | self.content_layout = None 20 | self.shortcut_input = None 21 | self.init_ui() 22 | self.self_close = False 23 | 24 | def init_ui(self): 25 | logging.debug('Initializing onboarding UI') 26 | self.setWindowTitle(_('Welcome to Writing Tools')) 27 | self.resize(600, 500) 28 | 29 | UIUtils.setup_window_and_layout(self) 30 | 31 | self.content_layout = QtWidgets.QVBoxLayout() 32 | self.content_layout.setContentsMargins(30, 30, 30, 30) 33 | self.content_layout.setSpacing(20) 34 | 35 | self.background.setLayout(self.content_layout) 36 | 37 | self.show_welcome_screen() 38 | 39 | def show_welcome_screen(self): 40 | UIUtils.clear_layout(self.content_layout) 41 | 42 | title_label = QtWidgets.QLabel(_("Welcome to Writing Tools")+"!") 43 | title_label.setStyleSheet(f"font-size: 24px; font-weight: bold; color: {'#ffffff' if colorMode == 'dark' else '#333333'};") 44 | self.content_layout.addWidget(title_label, alignment=QtCore.Qt.AlignmentFlag.AlignCenter) 45 | 46 | features_text = f""" 47 | • {_('Instantly optimize your writing with AI by selecting your text and invoking Writing Tools with "ctrl+space", anywhere.')} 48 | 49 | • {_('Get a summary you can chat with of articles, YouTube videos, or documents by select all text with "ctrl+a"')} 50 | {_('(or select the YouTube transcript from its description), invoking Writing Tools, and choosing Summary.')} 51 | 52 | • {_('Chat with AI anytime by invoking Writing Tools without selecting any text.')} 53 | 54 | • {_('Supports an extensive range of AI models:')} 55 | - {_('Gemini 2.0')} 56 | - {_('ANY OpenAI Compatible API — including local LLMs!')} 57 | """ 58 | features_label = QtWidgets.QLabel(features_text) 59 | features_label.setStyleSheet(f"font-size: 16px; color: {'#ffffff' if colorMode == 'dark' else '#333333'};") 60 | features_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) 61 | self.content_layout.addWidget(features_label) 62 | 63 | shortcut_label = QtWidgets.QLabel("Customize your shortcut key (default: \"ctrl+space\"):") 64 | shortcut_label.setStyleSheet(f"font-size: 16px; color: {'#ffffff' if colorMode == 'dark' else '#333333'};") 65 | self.content_layout.addWidget(shortcut_label) 66 | 67 | self.shortcut_input = QtWidgets.QLineEdit(self.shortcut) 68 | self.shortcut_input.setStyleSheet(f""" 69 | font-size: 16px; 70 | padding: 5px; 71 | background-color: {'#444' if colorMode == 'dark' else 'white'}; 72 | color: {'#ffffff' if colorMode == 'dark' else '#000000'}; 73 | border: 1px solid {'#666' if colorMode == 'dark' else '#ccc'}; 74 | """) 75 | self.content_layout.addWidget(self.shortcut_input) 76 | 77 | theme_label = QtWidgets.QLabel(_("Choose your theme:")) 78 | theme_label.setStyleSheet(f"font-size: 16px; color: {'#ffffff' if colorMode == 'dark' else '#333333'};") 79 | self.content_layout.addWidget(theme_label) 80 | 81 | theme_layout = QHBoxLayout() 82 | gradient_radio = QRadioButton(_("Gradient")) 83 | plain_radio = QRadioButton(_("Plain")) 84 | gradient_radio.setStyleSheet(f"color: {'#ffffff' if colorMode == 'dark' else '#333333'};") 85 | plain_radio.setStyleSheet(f"color: {'#ffffff' if colorMode == 'dark' else '#333333'};") 86 | gradient_radio.setChecked(self.theme == 'gradient') 87 | plain_radio.setChecked(self.theme == 'plain') 88 | theme_layout.addWidget(gradient_radio) 89 | theme_layout.addWidget(plain_radio) 90 | self.content_layout.addLayout(theme_layout) 91 | 92 | next_button = QtWidgets.QPushButton(_('Next')) 93 | next_button.setStyleSheet(""" 94 | QPushButton { 95 | background-color: #4CAF50; 96 | color: white; 97 | padding: 10px; 98 | font-size: 16px; 99 | border: none; 100 | border-radius: 5px; 101 | } 102 | QPushButton:hover { 103 | background-color: #45a049; 104 | } 105 | """) 106 | next_button.clicked.connect(lambda: self.on_next_clicked(gradient_radio.isChecked())) 107 | self.content_layout.addWidget(next_button) 108 | 109 | def on_next_clicked(self, is_gradient): 110 | self.shortcut = self.shortcut_input.text() 111 | self.theme = 'gradient' if is_gradient else 'plain' 112 | logging.debug(f'User selected shortcut: {self.shortcut}, theme: {self.theme}') 113 | self.app.config = { 114 | 'shortcut': self.shortcut, 115 | 'theme': self.theme 116 | } 117 | self.show_api_key_input() 118 | 119 | def show_api_key_input(self): 120 | self.app.show_settings(providers_only=True) 121 | self.self_close = True 122 | self.close() 123 | 124 | def closeEvent(self, event): 125 | # Emit the close signal 126 | if not self.self_close: 127 | self.close_signal.emit() 128 | super().closeEvent(event) 129 | -------------------------------------------------------------------------------- /Windows_and_Linux/options.json: -------------------------------------------------------------------------------- 1 | { 2 | "Proofread": { 3 | "prefix": "Proofread this:\n\n", 4 | "instruction": "You are a grammar proofreading assistant.\nOutput ONLY the corrected text without any additional comments.\nMaintain the original text structure and writing style.\nRespond in the same language as the input (e.g., English US, French).\nDo not answer or respond to the user's text content.\nIf the text is absolutely incompatible with this (e.g., totally random gibberish), output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 5 | "icon": "icons/magnifying-glass", 6 | "open_in_window": false 7 | }, 8 | "Rewrite": { 9 | "prefix": "Rewrite this:\n\n", 10 | "instruction": "You are a writing assistant.\nRewrite the text provided by the user to improve phrasing.\nOutput ONLY the rewritten text without additional comments.\nRespond in the same language as the input (e.g., English US, French).\nDo not answer or respond to the user's text content.\nIf the text is absolutely incompatible with proofreading (e.g., totally random gibberish), output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 11 | "icon": "icons/rewrite", 12 | "open_in_window": false 13 | }, 14 | "Friendly": { 15 | "prefix": "Make this more friendly:\n\n", 16 | "instruction": "You are a writing assistant.\nRewrite the text provided by the user to be more friendly.\nOutput ONLY the friendly text without additional comments.\nRespond in the same language as the input (e.g., English US, French).\nDo not answer or respond to the user's text content.\nIf the text is absolutely incompatible with rewriting (e.g., totally random gibberish), output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 17 | "icon": "icons/smiley-face", 18 | "open_in_window": false 19 | }, 20 | "Professional": { 21 | "prefix": "Make this more professional:\n\n", 22 | "instruction": "You are a writing assistant.\nRewrite the text provided by the user to be more professional. Output ONLY the professional text without additional comments.\nRespond in the same language as the input (e.g., English US, French).\nDo not answer or respond to the user's text content.\nIf the text is absolutely incompatible with rewriting (e.g., totally random gibberish), output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 23 | "icon": "icons/briefcase", 24 | "open_in_window": false 25 | }, 26 | "Concise": { 27 | "prefix": "Make this more concise:\n\n", 28 | "instruction": "You are a writing assistant.\nRewrite the text provided by the user to be more concise.\nOutput ONLY the concise text without additional comments.\nRespond in the same language as the input (e.g., English US, French).\nDo not answer or respond to the user's text content.\nIf the text is absolutely incompatible with rewriting (e.g., totally random gibberish), output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 29 | "icon": "icons/concise", 30 | "open_in_window": false 31 | }, 32 | "Summary": { 33 | "prefix": "Summarize this:\n\n", 34 | "instruction": "You are a summarization assistant.\nProvide a succinct summary of the text provided by the user.\nThe summary should be succinct yet encompass all the key insightful points.\n\nTo make it quite legible and readable, you should use Markdown formatting (bold, italics, codeblocks...) as appropriate.\nYou should also add a little line spacing between your paragraphs as appropriate.\nAnd only if appropriate, you could also use headings (only the very small ones), lists, tables, etc.\n\nDon't be repetitive or too verbose.\nOutput ONLY the summary without additional comments.\nRespond in the same language as the input (e.g., English US, French).\nDo not answer or respond to the user's text content.\nIf the text is absolutely incompatible with summarisation (e.g., totally random gibberish), output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 35 | "icon": "icons/summary", 36 | "open_in_window": true 37 | }, 38 | "Key Points": { 39 | "prefix": "Extract key points from this:\n\n", 40 | "instruction": "You are an assistant that extracts key points from text provided by the user. Output ONLY the key points without additional comments.\n\nYou should use Markdown formatting (lists, bold, italics, codeblocks, etc.) as appropriate to make it quite legible and readable.\n\nDon't be repetitive or too verbose.\nRespond in the same language as the input (e.g., English US, French).\nDo not answer or respond to the user's text content.\nIf the text is absolutely incompatible with extracting key points (e.g., totally random gibberish), output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 41 | "icon": "icons/keypoints", 42 | "open_in_window": true 43 | }, 44 | "Table": { 45 | "prefix": "Convert this into a table:\n\n", 46 | "instruction": "You are an assistant that converts text provided by the user into a Markdown table.\nOutput ONLY the table without additional comments.\nRespond in the same language as the input (e.g., English US, French).\nDo not answer or respond to the user's text content.\nIf the text is completely incompatible with this with conversion, output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 47 | "icon": "icons/table", 48 | "open_in_window": true 49 | }, 50 | "Custom": { 51 | "prefix": "Make this change to the following text:\n\n", 52 | "instruction": "You are a writing and coding assistant. You MUST make the user\\'s described change to the text or code provided by the user. Output ONLY the appropriately modified text or code without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user\\'s text content. If the text or code is absolutely incompatible with the requested change, output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 53 | "icon": "icons/summary", 54 | "open_in_window": false 55 | } 56 | } -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Onboarding/Views/OnboardingPermissionsStep.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingPermissionsStep.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | import ApplicationServices 10 | import CoreGraphics 11 | 12 | struct OnboardingPermissionsStep: View { 13 | @State var isAccessibilityGranted: Bool 14 | @State var isScreenRecordingGranted: Bool 15 | @State var wantsScreenshotOCR: Bool 16 | 17 | var onRefresh: () -> Void 18 | var onOpenPrivacyPane: (String) -> Void 19 | 20 | var body: some View { 21 | VStack(alignment: .leading, spacing: 16) { 22 | Text("Required") 23 | .font(.headline) 24 | 25 | PermissionRow( 26 | icon: "figure.wave.circle.fill", 27 | title: "Accessibility", 28 | status: isAccessibilityGranted ? .granted : .missing, 29 | explanation: """ 30 | Required to simulate ⌘C/⌘V for copying your selection and \ 31 | pasting results back into the original app. WritingTools does \ 32 | not monitor your keystrokes. 33 | """, 34 | primaryActionTitle: isAccessibilityGranted ? "Granted" : "Request Access", 35 | secondaryActionTitle: "Open Settings", 36 | onPrimary: { 37 | OnboardingPermissionsHelper.requestAccessibility() 38 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 39 | onRefresh() 40 | } 41 | }, 42 | onSecondary: { 43 | onOpenPrivacyPane("Privacy_Accessibility") 44 | } 45 | ) 46 | 47 | Divider().padding(.vertical, 4) 48 | 49 | Toggle(isOn: $wantsScreenshotOCR) { 50 | VStack(alignment: .leading, spacing: 2) { 51 | Text("Enable Screenshot OCR (Optional)") 52 | Text( 53 | "If enabled, you can run OCR on screenshot snippets. This requires Screen Recording permission." 54 | ) 55 | .font(.caption) 56 | .foregroundColor(.secondary) 57 | } 58 | } 59 | .toggleStyle(.switch) 60 | 61 | if wantsScreenshotOCR { 62 | PermissionRow( 63 | icon: "rectangle.dashed.and.paperclip", 64 | title: "Screen Recording (Optional)", 65 | status: isScreenRecordingGranted ? .granted : .missing, 66 | explanation: """ 67 | Required only if you use Screenshot OCR. macOS will show a \ 68 | system prompt. You may need to restart the app for changes to \ 69 | take effect. WritingTools does not record or store your \ 70 | screen; it only uses this to capture the area you explicitly \ 71 | select. 72 | """, 73 | primaryActionTitle: isScreenRecordingGranted ? "Granted" : "Request Access", 74 | secondaryActionTitle: "Open Settings", 75 | onPrimary: { 76 | OnboardingPermissionsHelper.requestScreenRecording { granted in 77 | DispatchQueue.main.async { 78 | isScreenRecordingGranted = granted 79 | } 80 | } 81 | }, 82 | onSecondary: { 83 | onOpenPrivacyPane("Privacy_ScreenCapture") 84 | } 85 | ) 86 | } 87 | 88 | GroupBox { 89 | VStack(alignment: .leading, spacing: 8) { 90 | Text("Notes") 91 | .font(.headline) 92 | VStack(alignment: .leading, spacing: 6) { 93 | Label( 94 | "You can revoke any permission later in System Settings.", 95 | systemImage: "info.circle" 96 | ) 97 | Label( 98 | "Input Monitoring is NOT required. WritingTools only posts copy/paste commands.", 99 | systemImage: "checkmark.circle" 100 | ) 101 | } 102 | .foregroundColor(.secondary) 103 | } 104 | .padding(8) 105 | } 106 | 107 | HStack { 108 | Button("Refresh Status") { 109 | onRefresh() 110 | } 111 | .buttonStyle(.bordered) 112 | .help("Recheck current permission statuses.") 113 | 114 | Spacer() 115 | 116 | Button("Open Privacy & Security") { 117 | NSWorkspace.shared.open( 118 | URL( 119 | string: 120 | "x-apple.systempreferences:com.apple.preference.security" 121 | )! 122 | ) 123 | } 124 | .buttonStyle(.link) 125 | .help("Open System Settings to manage permissions.") 126 | } 127 | .padding(.top, 4) 128 | } 129 | } 130 | } 131 | 132 | // MARK: - Permission Helpers 133 | 134 | struct OnboardingPermissionsHelper { 135 | static func requestAccessibility() { 136 | let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as CFString 137 | let options: CFDictionary = [key: true] as CFDictionary 138 | _ = AXIsProcessTrustedWithOptions(options) 139 | 140 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { 141 | if let url = URL( 142 | string: 143 | "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" 144 | ) { 145 | NSWorkspace.shared.open(url) 146 | } 147 | } 148 | } 149 | 150 | static func checkScreenRecording() -> Bool { 151 | if #available(macOS 10.15, *) { 152 | return CGPreflightScreenCaptureAccess() 153 | } else { 154 | return true 155 | } 156 | } 157 | 158 | static func requestScreenRecording(completion: @escaping (Bool) -> Void) { 159 | if #available(macOS 10.15, *) { 160 | DispatchQueue.global(qos: .userInitiated).async { 161 | let granted = CGRequestScreenCaptureAccess() 162 | completion(granted) 163 | } 164 | } else { 165 | completion(true) 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /macOS/WritingTools/Models/CustomCommand.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | struct CustomCommand: Codable, Identifiable, Equatable { 5 | let id: UUID 6 | var name: String 7 | var prompt: String 8 | var icon: String 9 | var useResponseWindow: Bool 10 | 11 | init( 12 | id: UUID = UUID(), 13 | name: String, 14 | prompt: String, 15 | icon: String, 16 | useResponseWindow: Bool = false 17 | ) { 18 | self.id = id 19 | self.name = name 20 | self.prompt = prompt 21 | self.icon = icon 22 | self.useResponseWindow = useResponseWindow 23 | } 24 | } 25 | 26 | class CustomCommandsManager: ObservableObject { 27 | @Published private(set) var commands: [CustomCommand] = [] 28 | 29 | private let saveKey = "custom_commands" 30 | 31 | // iCloud KVS 32 | private let iCloudStore = NSUbiquitousKeyValueStore.default 33 | private let iCloudDataKey = "icloud.custom_commands.v1.data" 34 | private let iCloudMTimeKey = "icloud.custom_commands.v1.mtime" 35 | private let localMTimeDefaultsKey = "custom_commands_mtime.v1" 36 | 37 | // Prevents push loops when applying remote changes 38 | private var isApplyingCloudChange = false 39 | 40 | private var kvsObserver: NSObjectProtocol? 41 | 42 | init() { 43 | // Load local first 44 | loadLocalCommands() 45 | 46 | // Start iCloud sync 47 | iCloudStore.synchronize() 48 | // Pull from iCloud if newer than local 49 | pullFromICloudIfNewer() 50 | 51 | // Observe KVS remote changes 52 | kvsObserver = NotificationCenter.default.addObserver( 53 | forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, 54 | object: iCloudStore, 55 | queue: .main 56 | ) { [weak self] note in 57 | self?.handleICloudChange(note) 58 | } 59 | } 60 | 61 | deinit { 62 | if let kvsObserver { 63 | NotificationCenter.default.removeObserver(kvsObserver) 64 | } 65 | } 66 | 67 | // MARK: - Public API 68 | 69 | func addCommand(_ command: CustomCommand) { 70 | commands.append(command) 71 | saveCommands() 72 | } 73 | 74 | func updateCommand(_ command: CustomCommand) { 75 | if let index = commands.firstIndex(where: { $0.id == command.id }) { 76 | commands[index] = command 77 | saveCommands() 78 | } 79 | } 80 | 81 | func deleteCommand(_ command: CustomCommand) { 82 | commands.removeAll { $0.id == command.id } 83 | saveCommands() 84 | } 85 | 86 | // Replace all custom commands at once (kept for your existing usage) 87 | func replaceCommands(with newCommands: [CustomCommand]) { 88 | commands = newCommands 89 | saveCommands() 90 | } 91 | 92 | // MARK: - Local persistence 93 | 94 | private func loadLocalCommands() { 95 | if 96 | let data = UserDefaults.standard.data(forKey: saveKey), 97 | let decoded = try? JSONDecoder().decode([CustomCommand].self, from: data) 98 | { 99 | commands = decoded 100 | } 101 | } 102 | 103 | private func saveLocalCommands() { 104 | if let encoded = try? JSONEncoder().encode(commands) { 105 | UserDefaults.standard.set(encoded, forKey: saveKey) 106 | } 107 | } 108 | 109 | // MARK: - iCloud sync 110 | 111 | // Push local -> iCloud, with modified time 112 | private func pushToICloud() { 113 | guard !isApplyingCloudChange else { return } 114 | 115 | do { 116 | let data = try JSONEncoder().encode(commands) 117 | let now = Date() 118 | 119 | iCloudStore.set(data, forKey: iCloudDataKey) 120 | iCloudStore.set(now, forKey: iCloudMTimeKey) 121 | iCloudStore.synchronize() 122 | 123 | UserDefaults.standard.set(now, forKey: localMTimeDefaultsKey) 124 | } catch { 125 | print("CustomCommandsManager: Failed to encode for iCloud: \(error)") 126 | } 127 | } 128 | 129 | // Pull iCloud -> local if iCloud is newer 130 | private func pullFromICloudIfNewer() { 131 | guard let remoteMTime = iCloudStore.object(forKey: iCloudMTimeKey) as? Date 132 | else { return } 133 | 134 | let localMTime = 135 | UserDefaults.standard.object(forKey: localMTimeDefaultsKey) as? Date 136 | 137 | guard localMTime == nil || remoteMTime > localMTime! else { 138 | return 139 | } 140 | 141 | guard let data = iCloudStore.data(forKey: iCloudDataKey) else { return } 142 | 143 | do { 144 | let remoteCommands = 145 | try JSONDecoder().decode([CustomCommand].self, from: data) 146 | 147 | isApplyingCloudChange = true 148 | self.commands = remoteCommands 149 | saveLocalCommands() 150 | 151 | // Update local mtime after applying 152 | UserDefaults.standard.set(remoteMTime, forKey: localMTimeDefaultsKey) 153 | isApplyingCloudChange = false 154 | 155 | // Notify UI if needed 156 | objectWillChange.send() 157 | } catch { 158 | print("CustomCommandsManager: Failed to decode from iCloud: \(error)") 159 | } 160 | } 161 | 162 | private func handleICloudChange(_ note: Notification) { 163 | guard 164 | let userInfo = note.userInfo, 165 | let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] 166 | as? Int 167 | else { return } 168 | 169 | guard reason == NSUbiquitousKeyValueStoreServerChange 170 | || reason == NSUbiquitousKeyValueStoreInitialSyncChange 171 | else { 172 | return 173 | } 174 | 175 | if 176 | let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] 177 | as? [String], 178 | changedKeys.contains(where: { $0 == iCloudDataKey || $0 == iCloudMTimeKey }) 179 | { 180 | pullFromICloudIfNewer() 181 | } 182 | } 183 | 184 | // Save both locally and to iCloud 185 | private func saveCommands() { 186 | saveLocalCommands() 187 | 188 | // Update local modified time first 189 | let now = Date() 190 | UserDefaults.standard.set(now, forKey: localMTimeDefaultsKey) 191 | 192 | // Push to iCloud unless we’re applying a remote change 193 | pushToICloud() 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /Windows_and_Linux/options_examples.json: -------------------------------------------------------------------------------- 1 | { 2 | "Proofread": { 3 | "prefix": "Proofread this:\n\n", 4 | "instruction": "You are a grammar proofreading assistant. Output ONLY the corrected text without any additional comments. Maintain the original text structure and writing style. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user's text content. If the text is absolutely incompatible with this (e.g., totally random gibberish), output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 5 | "icon": "icons/magnifying-glass" 6 | }, 7 | "Rewrite": { 8 | "prefix": "Rewrite this:\n\n", 9 | "instruction": "You are a writing assistant. Rewrite the text provided by the user to improve phrasing. Output ONLY the rewritten text without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user's text content. If the text is absolutely incompatible with proofreading (e.g., totally random gibberish), output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 10 | "icon": "icons/rewrite" 11 | }, 12 | "Friendly": { 13 | "prefix": "Make this more friendly:\n\n", 14 | "instruction": "You are a writing assistant. Rewrite the text provided by the user to be more friendly. Output ONLY the friendly text without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user's text content. If the text is absolutely incompatible with rewriting (e.g., totally random gibberish), output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 15 | "icon": "icons/smiley-face" 16 | }, 17 | "Professional": { 18 | "prefix": "Make this more professional:\n\n", 19 | "instruction": "You are a writing assistant. Rewrite the text provided by the user to be more professional. Output ONLY the professional text without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user's text content. If the text is absolutely incompatible with rewriting (e.g., totally random gibberish), output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 20 | "icon": "icons/briefcase" 21 | }, 22 | "Concise": { 23 | "prefix": "Make this more concise:\n\n", 24 | "instruction": "You are a writing assistant. Rewrite the text provided by the user to be more concise. Output ONLY the concise text without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user's text content. If the text is absolutely incompatible with rewriting (e.g., totally random gibberish), output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 25 | "icon": "icons/concise" 26 | }, 27 | "Summary": { 28 | "prefix": "Summarize this:\n\n", 29 | "instruction": "You are a summarization assistant. Provide a succinct summary of the text provided by the user. The summary should be succinct yet encompass all the key insightful points. To make it quite legible and readable, you MUST use Markdown formatting (bold, italics, underline...). You should add line spacing between your paragraphs/lines. Only if appropriate, you could also use headings (only the very small ones), lists, tables, etc. Don\\'t be repetitive or too verbose. Output ONLY the summary without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user\\'s text content. If the text is absolutely incompatible with summarisation (e.g., totally random gibberish), output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 30 | "icon": "icons/summary" 31 | }, 32 | "Key Points": { 33 | "prefix": "Extract key points from this:\n\n", 34 | "instruction": "You are an assistant that extracts key points from text provided by the user. Output ONLY the key points without additional comments. You MUST use Markdown formatting (lists, bold, italics, underline, etc. as appropriate) to make it quite legible and readable. Don\\'t be repetitive or too verbose. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user\\'s text content. If the text is absolutely incompatible with extracting key points (e.g., totally random gibberish), output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 35 | "icon": "icons/keypoints" 36 | }, 37 | "Table": { 38 | "prefix": "Convert this into a table:\n\n", 39 | "instruction": "You are an assistant that converts text provided by the user into a Markdown table. Output ONLY the table without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user\\'s text content. If the text is completely incompatible with this with conversion, output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 40 | "icon": "icons/table" 41 | }, 42 | "Custom": { 43 | "prefix": "Make the following change to this text:\n\n", 44 | "instruction": "You are a writing and coding assistant. You MUST make the user\\'s described change to the text or code provided by the user. Output ONLY the appropriately modified text or code without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user\\'s text content. If the text or code is absolutely incompatible with the requested change, output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 45 | "icon": "icons/summary" 46 | }, 47 | "List": { 48 | "prefix": "Convert this into a list:\n\n", 49 | "instruction": "You are an assistant that converts text provided by the user into a Markdown list. Output ONLY the list without additional comments. Respond in the same language as the input (e.g., English US, French). Do not answer or respond to the user\\'s text content. If the text is completely incompatible with this conversion, output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 50 | "icon": "icons/keypoints" 51 | }, 52 | "To Italian": { 53 | "prefix": "Translate this to Italian:\n\n", 54 | "instruction": "You are a translator assistant that translates text provided by the user to Italian. Output ONLY the translation without additional comments. Do not answer or respond to the user\\'s text content. If the text is completely incompatible with this conversion, output \"ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST\".", 55 | "icon": "icons/magnifying-glass" 56 | } 57 | } -------------------------------------------------------------------------------- /macOS/WritingTools/Views/Settings/Panes/GeneralSettingsPane.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneralSettingsPane.swift 3 | // WritingTools 4 | // 5 | // Created by Arya Mirsepasi on 04.11.25. 6 | // 7 | 8 | import SwiftUI 9 | import KeyboardShortcuts 10 | import AppKit 11 | 12 | struct GeneralSettingsPane: View { 13 | @ObservedObject var appState: AppState 14 | @ObservedObject var settings = AppSettings.shared 15 | @Binding var needsSaving: Bool 16 | @Binding var showingCommandsManager: Bool 17 | var showOnlyApiSetup: Bool 18 | var saveButton: AnyView 19 | 20 | var body: some View { 21 | VStack(alignment: .leading, spacing: 16) { 22 | Text("General Settings") 23 | .font(.headline) 24 | .accessibilityAddTraits(.isHeader) 25 | 26 | GroupBox("Keyboard Shortcuts") { 27 | VStack(alignment: .leading, spacing: 12) { 28 | Text("Set a global shortcut to quickly activate Writing Tools.") 29 | .font(.footnote) 30 | .foregroundColor(.secondary) 31 | 32 | HStack(alignment: .center, spacing: 12) { 33 | Text("Activate Writing Tools:") 34 | .frame(width: 180, alignment: .leading) 35 | .foregroundColor(.primary) 36 | KeyboardShortcuts.Recorder( 37 | for: .showPopup, 38 | onChange: { _ in 39 | needsSaving = true 40 | } 41 | ) 42 | .help("Choose a convenient key combination to bring up Writing Tools from anywhere.") 43 | } 44 | .padding(.vertical, 2) 45 | } 46 | } 47 | 48 | GroupBox("Commands") { 49 | VStack(alignment: .leading, spacing: 12) { 50 | Text("Manage your writing tools and assign keyboard shortcuts.") 51 | .font(.footnote) 52 | .foregroundColor(.secondary) 53 | 54 | Button(action: { 55 | showingCommandsManager = true 56 | }) { 57 | HStack(spacing: 8) { 58 | Image(systemName: "list.bullet.rectangle") 59 | Text("Manage Commands") 60 | } 61 | .frame(maxWidth: .infinity, alignment: .leading) 62 | .padding(.vertical, 8) 63 | .padding(.horizontal, 12) 64 | .background(Color(.controlBackgroundColor)) 65 | .cornerRadius(8) 66 | } 67 | .buttonStyle(.plain) 68 | .help("Open the Commands Manager to add, edit, or remove commands.") 69 | 70 | Toggle(isOn: $settings.openCustomCommandsInResponseWindow) { 71 | VStack(alignment: .leading, spacing: 2) { 72 | Text("Open custom prompts in response window") 73 | Text("When unchecked, custom prompts will replace selected text inline") 74 | .font(.caption) 75 | .foregroundColor(.secondary) 76 | } 77 | } 78 | .toggleStyle(.checkbox) 79 | .padding(.top, 4) 80 | .onChange(of: settings.openCustomCommandsInResponseWindow) { _, _ in 81 | needsSaving = true 82 | } 83 | .help("Choose whether custom prompts open in a separate response window or replace text inline.") 84 | } 85 | } 86 | 87 | GroupBox("Onboarding") { 88 | VStack(alignment: .leading, spacing: 8) { 89 | Text("You can rerun the onboarding flow to review permissions and quickly configure the app.") 90 | .font(.footnote) 91 | .foregroundColor(.secondary) 92 | 93 | HStack { 94 | Button { 95 | restartOnboarding() 96 | } label: { 97 | Label("Restart Onboarding", systemImage: "arrow.counterclockwise") 98 | } 99 | .buttonStyle(.bordered) 100 | .help("Open the onboarding window to set up WritingTools again.") 101 | 102 | Spacer() 103 | } 104 | } 105 | } 106 | 107 | Spacer() 108 | 109 | if !showOnlyApiSetup { 110 | saveButton 111 | } 112 | } 113 | .sheet(isPresented: $showingCommandsManager) { 114 | CommandsView(commandManager: appState.commandManager) 115 | } 116 | } 117 | 118 | private func restartOnboarding() { 119 | // Mark onboarding as not completed 120 | settings.hasCompletedOnboarding = false 121 | 122 | // Create the onboarding window the same way AppDelegate does 123 | let window = NSWindow( 124 | contentRect: NSRect(x: 0, y: 0, width: 640, height: 720), 125 | styleMask: [.titled, .closable, .miniaturizable, .resizable], 126 | backing: .buffered, 127 | defer: false 128 | ) 129 | window.title = "Onboarding" 130 | window.isReleasedWhenClosed = false 131 | 132 | let onboardingView = OnboardingView(appState: appState) 133 | let hostingView = NSHostingView(rootView: onboardingView) 134 | window.contentView = hostingView 135 | window.level = .floating 136 | 137 | // Register with WindowManager properly 138 | WindowManager.shared.setOnboardingWindow(window, hostingView: hostingView) 139 | window.makeKeyAndOrderFront(nil) 140 | 141 | // Optionally close Settings to reduce window clutter 142 | if let settingsWindow = NSApplication.shared.windows.first(where: { 143 | $0.contentView?.subviews.contains(where: { $0 is NSHostingView }) ?? false 144 | }) { 145 | settingsWindow.close() 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /macOS/WritingTools/Services/CommandManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | class CommandManager: ObservableObject { 5 | @Published private(set) var commands: [CommandModel] = [] 6 | 7 | private let saveKey = "unified_commands" 8 | private let hasInitializedKey = "has_initialized_commands" 9 | private let deletedDefaultsKey = "deleted_default_commands" 10 | 11 | // Track which default command IDs have been deleted 12 | private var deletedDefaultIds: Set = [] 13 | 14 | init() { 15 | loadDeletedDefaultIds() 16 | loadCommands() 17 | } 18 | 19 | // MARK: - Command Management 20 | 21 | func addCommand(_ command: CommandModel) { 22 | commands.append(command) 23 | saveCommands() 24 | } 25 | 26 | func updateCommand(_ command: CommandModel) { 27 | if let index = commands.firstIndex(where: { $0.id == command.id }) { 28 | commands[index] = command 29 | saveCommands() 30 | 31 | // Notify that commands have changed to update shortcuts 32 | NotificationCenter.default.post(name: NSNotification.Name("CommandsChanged"), object: nil) 33 | } 34 | } 35 | 36 | func deleteCommand(_ command: CommandModel) { 37 | commands.removeAll { $0.id == command.id } 38 | 39 | // If it's a built-in command, track its ID as deleted 40 | if command.isBuiltIn { 41 | deletedDefaultIds.insert(command.id) 42 | saveDeletedDefaultIds() 43 | } 44 | 45 | saveCommands() 46 | } 47 | 48 | func moveCommand(fromOffsets source: IndexSet, toOffset destination: Int) { 49 | commands.move(fromOffsets: source, toOffset: destination) 50 | saveCommands() 51 | } 52 | 53 | // Public method to replace all commands 54 | func replaceAllCommands(with newCommands: [CommandModel]) { 55 | commands = newCommands 56 | saveCommands() 57 | } 58 | 59 | // MARK: - Getters with filtering 60 | 61 | var builtInCommands: [CommandModel] { 62 | commands.filter { $0.isBuiltIn } 63 | } 64 | 65 | var customCommands: [CommandModel] { 66 | commands.filter { !$0.isBuiltIn } 67 | } 68 | 69 | // MARK: - Data Persistence 70 | 71 | private func loadCommands() { 72 | // Check if we've initialized commands before 73 | let hasInitialized = UserDefaults.standard.bool(forKey: hasInitializedKey) 74 | 75 | if !hasInitialized { 76 | // First run, set up default commands 77 | initializeDefaultCommands() 78 | return 79 | } 80 | 81 | // Normal load 82 | if let data = UserDefaults.standard.data(forKey: saveKey), 83 | let decoded = try? JSONDecoder().decode([CommandModel].self, from: data) { 84 | self.commands = decoded 85 | } else { 86 | // Fallback if something went wrong with loading 87 | initializeDefaultCommands() 88 | } 89 | } 90 | 91 | private func saveCommands() { 92 | if let encoded = try? JSONEncoder().encode(commands) { 93 | UserDefaults.standard.set(encoded, forKey: saveKey) 94 | } 95 | } 96 | 97 | private func loadDeletedDefaultIds() { 98 | if let data = UserDefaults.standard.data(forKey: deletedDefaultsKey), 99 | let decoded = try? JSONDecoder().decode(Set.self, from: data) { 100 | self.deletedDefaultIds = decoded 101 | } 102 | } 103 | 104 | private func saveDeletedDefaultIds() { 105 | if let encoded = try? JSONEncoder().encode(deletedDefaultIds) { 106 | UserDefaults.standard.set(encoded, forKey: deletedDefaultsKey) 107 | } 108 | } 109 | 110 | // MARK: - Default Commands 111 | 112 | private func initializeDefaultCommands() { 113 | // Get the default commands and filter out any that are in the deleted list 114 | var defaultCmds = CommandModel.defaultCommands 115 | defaultCmds = defaultCmds.filter { !deletedDefaultIds.contains($0.id) } 116 | 117 | // Set up commands (if any custom commands, they will be added later) 118 | self.commands = defaultCmds 119 | saveCommands() 120 | 121 | // Mark as initialized 122 | UserDefaults.standard.set(true, forKey: hasInitializedKey) 123 | } 124 | 125 | // MARK: - Reset to Defaults 126 | 127 | func resetToDefaults() { 128 | // Get only the custom commands (not built-in) 129 | let customCommands = self.commands.filter { !$0.isBuiltIn } 130 | 131 | // Get all the default commands (including previously deleted ones) 132 | let defaultCommands = CommandModel.defaultCommands 133 | 134 | // Clear the deleted defaults tracking 135 | deletedDefaultIds.removeAll() 136 | saveDeletedDefaultIds() 137 | 138 | // Reset to factory defaults and keep custom commands 139 | self.commands = defaultCommands + customCommands 140 | 141 | // Save the changes 142 | saveCommands() 143 | } 144 | 145 | // MARK: - Migration Helpers 146 | 147 | func migrateFromLegacySystems(customCommands: [CustomCommand]) { 148 | // Get existing commands but filter out built-in ones (which we'll be replacing) 149 | let existingCustom = self.commands.filter { !$0.isBuiltIn } 150 | 151 | // Convert legacy custom commands 152 | let convertedCustom = customCommands.map { CommandModel.fromCustomCommand($0) } 153 | 154 | // Get default commands but filter out deleted ones 155 | var defaultCmds = CommandModel.defaultCommands 156 | defaultCmds = defaultCmds.filter { !deletedDefaultIds.contains($0.id) } 157 | 158 | // Set commands to be: 159 | // 1. Default built-in commands (except deleted ones) 160 | // 2. Any existing custom commands we already have in the new system 161 | // 3. Newly converted custom commands from the legacy system 162 | self.commands = defaultCmds + existingCustom + convertedCustom 163 | 164 | // Remove any duplicates (by name) 165 | let uniqueCommands = Dictionary(grouping: self.commands, by: { $0.name }) 166 | .compactMap { $1.first } 167 | 168 | self.commands = uniqueCommands 169 | saveCommands() 170 | } 171 | } -------------------------------------------------------------------------------- /macOS/README.md: -------------------------------------------------------------------------------- 1 | # Writing Tools for macOS (Native Swift Port) 2 | 3 | > System-wide AI writing superpowers for Mac — **native Swift**, **privacy-first**, and **insanely fast** on Apple Silicon. 4 | 5 | [Back to root README](../README.md) 6 | 7 | --- 8 | 9 | ## Table of Contents 10 | - [Highlights](#highlights) 11 | - [Quick Start (Download & Install)](#quick-start-download--install) 12 | - [First Launch: Permissions](#first-launch-permissions) 13 | - [Using Writing Tools](#using-writing-tools) 14 | - [Providers & Models](#providers--models) 15 | - [Power Features](#power-features) 16 | - [System Requirements](#system-requirements) 17 | - [Build From Source (Xcode)](#build-from-source-xcode) 18 | - [Troubleshooting](#troubleshooting) 19 | - [Privacy](#privacy) 20 | - [Credits](#credits) 21 | - [License](#license) 22 | 23 | --- 24 | 25 | ## Highlights 26 | 27 | - **Truly native**: Built in Swift (SwiftUI + AppKit where helpful) for a crisp Mac experience. 28 | - **Local LLMs with MLX**: Run models **fully on-device** on Apple Silicon. No internet required. 29 | - **Rich Text Proofread**: Keep **RTF formatting** (bold, italics, lists, links) while fixing grammar and tone. 30 | - **Your workflow, your rules**: Add/edit **custom commands** and assign your own **shortcuts**. 31 | - **Multilingual**: App UI in **English, German, French, Spanish**; commands work with many more languages. 32 | - **Themes**: Multiple themes including Dark Mode to match your desktop. 33 | 34 | --- 35 | 36 | ## Quick Start (Download & Install) 37 | 38 | 1) **Download** the latest `.dmg` from **Releases**: 39 | https://github.com/theJayTea/WritingTools/releases 40 | 41 | 2) **Install** 42 | - Open the `.dmg`, drag **Writing Tools.app** into **Applications**. 43 | - On first open, if Gatekeeper warns, right-click the app → **Open**. 44 | 45 | 3) **Run** 46 | - Launch the app. 47 | - Open **Settings** and choose your **AI Provider** (local MLX, Ollama, or a cloud provider). 48 | - Assign your preferred **keyboard shortcut**. 49 | 50 | --- 51 | 52 | ## First Launch: Permissions 53 | 54 | Writing Tools uses macOS accessibility to read and replace selected text. 55 | On first run, grant: 56 | 57 | - **Accessibility** (required) 58 | - **Screen Recording** (only needed for some apps that restrict text access) 59 | 60 | You can manage these anytime under: 61 | **System Settings → Privacy & Security → Accessibility / Screen Recording**. :contentReference[oaicite:0]{index=0} 62 | 63 | > Tip: If replacement doesn’t work in a specific app, enabling **Screen Recording** usually fixes it. 64 | 65 | --- 66 | 67 | ## Using Writing Tools 68 | 69 | - **Invoke anywhere**: Select text in any app → press your shortcut → choose an action: 70 | - **Proofread** (keeps RTF formatting) 71 | - **Rewrite**, **Make Friendly**, **Make Professional**, **Concise** 72 | - **Summarize**, **Key Points**, **Table** 73 | - **Custom command** (your own prompt) 74 | - **No selection?** Your shortcut opens a quick **Chat** with the current model. 75 | - **Undo**: If you don’t like the result, simply undo in the target app. 76 | 77 | > Shortcut conflicts? Check **System Settings → Keyboard → Keyboard Shortcuts** (Spotlight/Input Sources) and pick an alternative combo in the app’s Settings. 78 | 79 | --- 80 | 81 | ## Providers & Models 82 | 83 | - **Cloud**: OpenAI, Google (Gemini), Anthropic, Mistral, OpenRouter 84 | - **Local**: 85 | - **MLX (Apple Silicon)** — first-class, on-device inference 86 | - **Ollama** via OpenAI-compatible endpoint 87 | 88 | Bring your own API keys, switch providers anytime, and mix local + cloud based on your task. 89 | 90 | --- 91 | 92 | ## Power Features 93 | 94 | - **Command Editor**: Create reusable buttons for your own prompts; assign per-command shortcuts. 95 | - **Model Flexibility**: Choose the best model for proofreading vs. summarization vs. chat. 96 | - **Localization**: UI in **EN / DE / FR / ES**; commands happily accept and output many languages. 97 | - **Document-friendly**: **RTF-preserving Proofread** keeps the look of your document intact. 98 | 99 | --- 100 | 101 | ## System Requirements 102 | 103 | - **macOS 14.0 or later** (due to Accessibility APIs used for selection/replacement). :contentReference[oaicite:1]{index=1} 104 | - **Apple Silicon** recommended for MLX local models (runs on-device for privacy and speed). 105 | - For development: **Xcode 15+**. 106 | 107 | --- 108 | 109 | ## Build From Source (Xcode) 110 | 111 | You can build the macOS app either by opening the project or the package: 112 | 113 | **Option A — Open project (if present)** 114 | 1. `git clone https://github.com/theJayTea/WritingTools.git` 115 | 2. Open **WritingTools/macOS/** and double-click the **.xcodeproj**. 116 | 3. Select target **Writing Tools** → **Signing & Capabilities** → choose your Development Team. 117 | 4. Set **Deployment Target** to **macOS 14.0** (or higher). 118 | 5. Run on **My Mac** (⌘R). 119 | 120 | **Option B — Open the folder / Package.swift** 121 | 1. `git clone https://github.com/theJayTea/WritingTools.git` 122 | 2. In Xcode: **File → Open…** → choose **WritingTools/macOS** (or the repo root). 123 | 3. Let Xcode resolve Swift Packages, then configure **Signing** and **Deployment Target** as above. 124 | 4. Run on **My Mac** (⌘R). 125 | 126 | > First debug run will trigger macOS permission prompts (Accessibility / Screen Recording). Accept them and relaunch if prompted. :contentReference[oaicite:2]{index=2} 127 | 128 | --- 129 | 130 | ## Troubleshooting 131 | 132 | - **Shortcut doesn’t trigger** 133 | - Pick another combo in Settings (avoid Spotlight/Input Sources defaults). 134 | - **Text not replaced in a specific app** 135 | - Ensure **Accessibility** is allowed; enable **Screen Recording** for that app scenario. 136 | - **Local model not responding** 137 | - For **MLX**: confirm the model is available and selected in Settings. 138 | - For **Ollama**: verify the server is running and the **Base URL / Model** fields match your local model name. 139 | - **Permissions got reset** 140 | - Remove and re-add the app under: **System Settings → Privacy & Security**. 141 | 142 | --- 143 | 144 | ## Privacy 145 | 146 | - Nothing is sent anywhere unless **you** invoke an action. 147 | - API keys are stored **locally** on your device. 148 | - Use **MLX** to keep all processing **on-device** (no network). 149 | 150 | --- 151 | 152 | ## Credits 153 | 154 | - **macOS Port**: **Arya Mirsepasi** 155 | - **Gemini Image/Picture Processing**: **Joaov41**. 156 | - **OpenAI Compatible API Fix**: **drankush** 157 | - **Text size fix**: **gdmka** 158 | - **Keyboard Shortcuts**: Thanks to **sindresorhus/KeyboardShortcuts**. 159 | - **MLX Swift** (local LLMs on Apple Silicon): https://github.com/ml-explore/mlx-swift-examples 160 | 161 | 162 | 163 | ## License 164 | 165 | Distributed under the **GNU GPL v3**. 166 | --------------------------------------------------------------------------------