├── 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 |
--------------------------------------------------------------------------------