Skip to content

Commit

Permalink
Merge pull request #110 from Aryamirsepasi/main
Browse files Browse the repository at this point in the history
version 2.0 update
  • Loading branch information
theJayTea authored Jan 26, 2025
2 parents bf63c03 + 79d77cf commit 0854a65
Show file tree
Hide file tree
Showing 18 changed files with 552 additions and 240 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ Helped add the start-on-boot setting!
### macOS version:
#### A native Swift port created entirely by **[Aryamirsepasi](https://github.com/Aryamirsepasi)**! This was a big endeavour and they've done an amazing job. We're grateful to have them as a contributor. 🫡

**1. [Joaov41](https://github.com/Joaov41):**

Developed the amazing picture processing functionality for WritingTools, allowing the app to now work with images in addition to text!

## 🤝 Contributing

I welcome contributions! :D
Expand Down
2 changes: 1 addition & 1 deletion macOS/Latest_Version_for_Update_Check.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1
2
6 changes: 5 additions & 1 deletion macOS/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ The macOS port is being developed by **Aryamirsepasi**.

GitHub: [https://github.com/Aryamirsepasi](https://github.com/Aryamirsepasi)

The amazing picture processing functionality was created by **Joaov41**
GitHub: [https://github.com/Joaov41](https://github.com/Joaov41)

Special Thanks to @sindresorhus for developing an amazing and stable keyboard shortcuts package for Swift.

GitHub: [https://github.com/sindresorhus/KeyboardShortcuts](https://github.com/sindresorhus/KeyboardShortcuts)
GitHub: [https://github.com/sindresorhus/KeyboardShortcuts](https://github.com/sindresorhus/KeyboardShortcuts)

4 changes: 2 additions & 2 deletions macOS/writing-tools.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 2.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.aryamirsepasi.writing-tools";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down Expand Up @@ -458,7 +458,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 2.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.aryamirsepasi.writing-tools";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down
164 changes: 94 additions & 70 deletions macOS/writing-tools/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
self?.showOnboarding()
}

self?.requestAccessibilityPermissions()
}

KeyboardShortcuts.onKeyUp(for: .showPopup) { [weak self] in
Expand Down Expand Up @@ -135,23 +134,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
alert.runModal()
}

// Checks and requests accessibility permissions needed for app functionality
private func requestAccessibilityPermissions() {
let trusted = AXIsProcessTrusted()
if !trusted {
let alert = NSAlert()
alert.messageText = "Accessibility Access Required"
alert.informativeText = "Writing Tools needs accessibility access to detect text selection and simulate keyboard shortcuts. Please grant access in System Settings > Privacy & Security > Accessibility."
alert.alertStyle = .warning
alert.addButton(withTitle: "Open System Settings")
alert.addButton(withTitle: "Later")

if alert.runModal() == .alertFirstButtonReturn {
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!)
}
}
}

// Shows the first-time setup/onboarding window
private func showOnboarding() {
let window = NSWindow(
Expand Down Expand Up @@ -241,18 +223,38 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }

// Store the current frontmost application before showing popup
if let frontmostApp = NSWorkspace.shared.frontmostApplication {
self.appState.previousApplication = frontmostApp
if let currentFrontmostApp = NSWorkspace.shared.frontmostApplication {
self.appState.previousApplication = currentFrontmostApp
}

self.closePopupWindow()

let pasteboard = NSPasteboard.general
let oldContents = pasteboard.string(forType: .string)
pasteboard.clearContents()
let generalPasteboard = NSPasteboard.general

// Get initial pasteboard content
let oldContents = generalPasteboard.string(forType: .string)

// Simulate copy command
// Prioritized image types (in order of preference)
let supportedImageTypes = [
NSPasteboard.PasteboardType("public.png"),
NSPasteboard.PasteboardType("public.jpeg"),
NSPasteboard.PasteboardType("public.tiff"),
NSPasteboard.PasteboardType("com.compuserve.gif"),
NSPasteboard.PasteboardType("public.image")
]
var foundImage: Data? = nil

// Try to find the first available image in order of preference
for type in supportedImageTypes {
if let data = generalPasteboard.data(forType: type) {
foundImage = data
NSLog("Selected image type: \(type)")
break // Take only the first matching format
}
}

// Clear and perform copy command
generalPasteboard.clearContents()
let source = CGEventSource(stateID: .hidSystemState)
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x08, keyDown: true)
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x08, keyDown: false)
Expand All @@ -263,21 +265,26 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {

DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self else { return }
let selectedText = pasteboard.string(forType: .string) ?? ""
let selectedText = generalPasteboard.string(forType: .string) ?? ""

// Update app state with found image if any
self.appState.selectedImages = foundImage.map { [$0] } ?? []

pasteboard.clearContents()
generalPasteboard.clearContents()
if let oldContents = oldContents {
pasteboard.setString(oldContents, forType: .string)
generalPasteboard.setString(oldContents, forType: .string)
}

// Create window even if no text is selected
let window = PopupWindow(appState: self.appState)
window.delegate = self

self.appState.selectedText = selectedText
self.popupWindow = window

if selectedText.isEmpty {
// Set appropriate window size based on content
if !selectedText.isEmpty || !self.appState.selectedImages.isEmpty {
window.setContentSize(NSSize(width: 400, height: 400))
} else {
window.setContentSize(NSSize(width: 400, height: 100))
}

Expand All @@ -298,6 +305,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
existingWindow.cleanup()
existingWindow.close()

self.appState.selectedImages = []
self.popupWindow = nil
}
}
Expand All @@ -324,54 +332,70 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {

// Service handler for processing selected text
@objc func handleSelectedText(_ pboard: NSPasteboard, userData: String, error: AutoreleasingUnsafeMutablePointer<NSString>) {
let types: [NSPasteboard.PasteboardType] = [
.string,
.rtf,
NSPasteboard.PasteboardType("public.plain-text")
]

guard let selectedText = types.lazy.compactMap({ pboard.string(forType: $0) }).first,
!selectedText.isEmpty else {
error.pointee = "No text was selected" as NSString
return
}

// Store the current frontmost application
if let frontmostApp = NSWorkspace.shared.frontmostApplication {
appState.previousApplication = frontmostApp
}

// Store the selected text
appState.selectedText = selectedText

// Set service trigger flag
isServiceTriggered = true

// Show the popup
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }

let window = PopupWindow(appState: self.appState)
window.delegate = self
// Prioritized image types (in order of preference)
let supportedImageTypes = [
NSPasteboard.PasteboardType("public.png"),
NSPasteboard.PasteboardType("public.jpeg"),
NSPasteboard.PasteboardType("public.tiff"),
NSPasteboard.PasteboardType("com.compuserve.gif"),
NSPasteboard.PasteboardType("public.image")
]

self.closePopupWindow()
self.popupWindow = window
var foundImage: Data? = nil

// Configure window for service mode
window.level = .floating
window.collectionBehavior = [.moveToActiveSpace]
// Try to find the first available image in order of preference
for type in supportedImageTypes {
if let data = pboard.data(forType: type) {
foundImage = data
NSLog("Selected image type (Service): \(type)")
break // Take only the first matching format
}
}

window.positionNearMouse()
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
let textTypes: [NSPasteboard.PasteboardType] = [
.string,
.rtf,
NSPasteboard.PasteboardType("public.plain-text")
]

// Activate our app
NSApp.activate()
guard let selectedText = textTypes.lazy.compactMap({ pboard.string(forType: $0) }).first,
!selectedText.isEmpty else {
error.pointee = "No text was selected" as NSString
return
}

appState.selectedText = selectedText
appState.selectedImages = foundImage.map { [$0] } ?? []
isServiceTriggered = true

// Reset the service trigger flag after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.isServiceTriggered = false
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }

let window = PopupWindow(appState: self.appState)
window.delegate = self

self.closePopupWindow()
self.popupWindow = window

window.level = .floating
window.collectionBehavior = [.moveToActiveSpace]

window.positionNearMouse()
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()

NSApp.activate()

DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.isServiceTriggered = false
}
}
} else {
error.pointee = "Could not determine frontmost application" as NSString
return
}
}
}
Expand All @@ -395,7 +419,7 @@ extension AppDelegate {

// Register services provider
NSApp.servicesProvider = self

// Register the service
NSUpdateDynamicServices()
}
Expand Down
48 changes: 39 additions & 9 deletions macOS/writing-tools/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,32 @@ class AppState: ObservableObject {

@Published var geminiProvider: GeminiProvider
@Published var openAIProvider: OpenAIProvider
@Published var mistralProvider: MistralProvider

@Published var customInstruction: String = ""
@Published var selectedText: String = ""
@Published var isPopupVisible: Bool = false
@Published var isProcessing: Bool = false
@Published var previousApplication: NSRunningApplication?

// Derived from AppSettings
var currentProvider: String {
get { AppSettings.shared.currentProvider }
set { AppSettings.shared.currentProvider = newValue }
}
@Published var selectedImages: [Data] = [] // Store selected image data

// Current provider with UI binding support
@Published private(set) var currentProvider: String

var activeProvider: any AIProvider {
currentProvider == "openai" ? openAIProvider : geminiProvider
if currentProvider == "openai" {
return openAIProvider
} else if currentProvider == "gemini" {
return geminiProvider
} else {
return mistralProvider
}
}

private init() {
// Read from AppSettings
let asettings = AppSettings.shared
self.currentProvider = asettings.currentProvider

// Initialize Gemini
let geminiConfig = GeminiConfig(apiKey: asettings.geminiApiKey,
Expand All @@ -41,7 +47,15 @@ class AppState: ObservableObject {
)
self.openAIProvider = OpenAIProvider(config: openAIConfig)

if asettings.openAIApiKey.isEmpty && asettings.geminiApiKey.isEmpty {
// Initialize Mistral
let mistralConfig = MistralConfig(
apiKey: asettings.mistralApiKey,
baseURL: asettings.mistralBaseURL,
model: asettings.mistralModel
)
self.mistralProvider = MistralProvider(config: mistralConfig)

if asettings.openAIApiKey.isEmpty && asettings.geminiApiKey.isEmpty && asettings.mistralApiKey.isEmpty {
print("Warning: No API keys configured.")
}
}
Expand Down Expand Up @@ -70,8 +84,24 @@ class AppState: ObservableObject {
openAIProvider = OpenAIProvider(config: config)
}

// Switch AI provider
// Update provider and persist to settings
func setCurrentProvider(_ provider: String) {
currentProvider = provider
AppSettings.shared.currentProvider = provider
objectWillChange.send() // Explicitly notify observers
}

func saveMistralConfig(apiKey: String, baseURL: String, model: String) {
let asettings = AppSettings.shared
asettings.mistralApiKey = apiKey
asettings.mistralBaseURL = baseURL
asettings.mistralModel = model

let config = MistralConfig(
apiKey: apiKey,
baseURL: baseURL,
model: model
)
mistralProvider = MistralProvider(config: config)
}
}
4 changes: 2 additions & 2 deletions macOS/writing-tools/Models/AIProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ protocol AIProvider: ObservableObject {
// Indicates if provider is processing a request
var isProcessing: Bool { get set }

// Process text with optional system prompt
func processText(systemPrompt: String?, userPrompt: String) async throws -> String
// Process text with optional system prompt and images
func processText(systemPrompt: String?, userPrompt: String, images: [Data]) async throws -> String

// Cancel ongoing requests
func cancel()
Expand Down
Loading

0 comments on commit 0854a65

Please sign in to comment.