Skip to content

Commit c16e487

Browse files
committed
support for safari
1 parent 1d40284 commit c16e487

File tree

4 files changed

+190
-55
lines changed

4 files changed

+190
-55
lines changed

.cursor/rules/context-manager-architecture.mdc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ alwaysApply: false
66
# ContextManager Architecture Rule
77

88
## Core Principle
9-
The [ContextManager.swift](mdc:Flitro/ContextManager.swift) must remain completely independent of UI code and should never import SwiftUI or any UI-related frameworks.
9+
- The [ContextManager.swift](mdc:Flitro/ContextManager.swift) must remain completely independent of UI code and should never import SwiftUI or any UI-related frameworks.
10+
- ContextManager must keep track of all applications, documents, windows it has opened for a given context so that when closing the context, it can correctly close the related applications, documents and windows.
1011

1112
## Allowed Imports
1213
ContextManager should only import:

Flitro/ContextManager.swift

Lines changed: 162 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,10 @@ class ContextManager: ObservableObject {
7777
@Published var contexts: [Context] = []
7878
@Published var activeContext: Context?
7979

80-
// Mapping from context UUID to Chrome window ID
81-
private var chromeWindowIDs: [UUID: Int] = [:]
80+
// Mapping from context UUID to Chrome window IDs
81+
private var chromeWindowIDs: [UUID: [Int]] = [:]
82+
// Mapping from context UUID to Safari window IDs
83+
private var safariWindowIDs: [UUID: [Int]] = [:]
8284

8385
private let appName = Bundle.main.bundleIdentifier ?? "Flitro"
8486
private let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
@@ -243,6 +245,8 @@ class ContextManager: ObservableObject {
243245

244246
// Close any Chrome window opened for this context (if tracked)
245247
closeChromeWindowForContext(context.id)
248+
// Close any Safari windows opened for this context (if tracked)
249+
closeSafariWindowsForContext(context.id)
246250
}
247251

248252
private func isSystemApp(_ bundleIdentifier: String) -> Bool {
@@ -295,29 +299,41 @@ class ContextManager: ObservableObject {
295299
}
296300

297301
private func launchContextBrowserTabs(_ context: Context) {
298-
// Determine if Chrome is the default browser
299-
let isChromeDefault = getDefaultBrowser() == "chrome"
300-
// Group tabs by browser, including 'default' tabs in Chrome if Chrome is default
301-
let chromeTabs: [BrowserTab]
302-
let otherTabs: [BrowserTab]
303-
if isChromeDefault {
304-
chromeTabs = context.browserTabs.filter { tab in
305-
let b = tab.browser.lowercased()
306-
return b == "chrome" || b == "default"
307-
}
308-
otherTabs = context.browserTabs.filter { tab in
309-
let b = tab.browser.lowercased()
310-
return b != "chrome" && b != "default"
311-
}
312-
} else {
313-
chromeTabs = context.browserTabs.filter { $0.browser.lowercased() == "chrome" }
314-
otherTabs = context.browserTabs.filter { $0.browser.lowercased() != "chrome" }
302+
// Group tabs by explicit browser type
303+
var chromeTabs = context.browserTabs.filter { $0.browser.lowercased() == "chrome" }
304+
var safariTabs = context.browserTabs.filter { $0.browser.lowercased() == "safari" }
305+
var firefoxTabs = context.browserTabs.filter { $0.browser.lowercased() == "firefox" }
306+
var otherTabs = context.browserTabs.filter { !["chrome", "safari", "firefox", "default"].contains($0.browser.lowercased()) }
307+
let defaultTabs = context.browserTabs.filter { $0.browser.lowercased() == "default" }
308+
309+
// Assign default tabs to the detected default browser
310+
let defaultBrowser = getDefaultBrowser()
311+
switch defaultBrowser {
312+
case "chrome":
313+
chromeTabs += defaultTabs
314+
case "safari":
315+
safariTabs += defaultTabs
316+
case "firefox":
317+
firefoxTabs += defaultTabs
318+
default:
319+
otherTabs += defaultTabs
315320
}
321+
316322
// Open Chrome tabs using Apple Events
317323
if !chromeTabs.isEmpty {
318324
print("Launching Chrome tabs: \(chromeTabs)")
319325
_ = launchChromeTabsWithAppleEvents(chromeTabs, for: context.id)
320326
}
327+
// Open Safari tabs using Apple Events
328+
if !safariTabs.isEmpty {
329+
print("Launching Safari tabs: \(safariTabs)")
330+
_ = launchSafariTabsWithAppleEvents(safariTabs, for: context.id)
331+
}
332+
// Open Firefox tabs
333+
if !firefoxTabs.isEmpty {
334+
print("Launching Firefox tabs: \(firefoxTabs)")
335+
_ = launchFirefoxTabs(firefoxTabs, for: context.id)
336+
}
321337
// Open other tabs using the existing method
322338
for tab in otherTabs {
323339
openBrowserTab(tab)
@@ -334,23 +350,97 @@ class ContextManager: ObservableObject {
334350
}
335351
}
336352

337-
private func launchSafariTabs(_ tabs: [BrowserTab], for contextId: UUID) -> Bool {
353+
/// Launch Safari tabs using Apple Events (AppleScript), creating a new window and opening all tabs
354+
private func launchSafariTabsWithAppleEvents(_ tabs: [BrowserTab], for contextId: UUID) -> Bool {
338355
guard !tabs.isEmpty else { return false }
339-
let workspace = NSWorkspace.shared
340-
guard let safariURL = workspace.urlForApplication(withBundleIdentifier: "com.apple.Safari") else {
341-
return false
356+
let safariIsRunning = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Safari").count > 0
357+
let urls = tabs.map { $0.url }.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
358+
guard !urls.isEmpty else { return false }
359+
let urlList = urls.map { "\"\($0)\"" }.joined(separator: ", ")
360+
let script: String
361+
if urls.count == 1 {
362+
if safariIsRunning {
363+
script = """
364+
tell application \"Safari\"
365+
activate
366+
make new document
367+
set URL of current tab of front window to \(urlList)
368+
set winId to id of front window
369+
return winId
370+
end tell
371+
"""
372+
} else {
373+
script = """
374+
tell application \"Safari\"
375+
activate
376+
delay 0.5
377+
try
378+
close window 1
379+
end try
380+
make new document
381+
set URL of current tab of front window to \(urlList)
382+
set winId to id of front window
383+
return winId
384+
end tell
385+
"""
386+
}
387+
} else {
388+
if safariIsRunning {
389+
script = """
390+
tell application \"Safari\"
391+
activate
392+
make new document
393+
set tabUrls to { \(urlList) }
394+
set URL of current tab of front window to (item 1 of tabUrls)
395+
repeat with i from 2 to count of tabUrls
396+
tell front window to set newTab to make new tab at end of tabs
397+
set URL of newTab to (item i of tabUrls)
398+
end repeat
399+
set winId to id of front window
400+
return winId
401+
end tell
402+
"""
403+
} else {
404+
script = """
405+
tell application \"Safari\"
406+
activate
407+
delay 0.5
408+
try
409+
close window 1
410+
end try
411+
make new document
412+
set tabUrls to { \(urlList) }
413+
set URL of current tab of front window to (item 1 of tabUrls)
414+
repeat with i from 2 to count of tabUrls
415+
tell front window to set newTab to make new tab at end of tabs
416+
set URL of newTab to (item i of tabUrls)
417+
end repeat
418+
set winId to id of front window
419+
return winId
420+
end tell
421+
"""
422+
}
342423
}
343-
let config = NSWorkspace.OpenConfiguration()
344-
for tab in tabs {
345-
if let url = URL(string: tab.url) {
346-
workspace.open([url], withApplicationAt: safariURL, configuration: config) { app, error in
347-
if let error = error {
348-
print("Failed to open URL in Safari: \(error)")
349-
}
424+
print(script)
425+
if let appleScript = NSAppleScript(source: script) {
426+
var error: NSDictionary? = nil
427+
let result = appleScript.executeAndReturnError(&error)
428+
if let error = error {
429+
print("AppleScript error (Safari): \(error)")
430+
return false
431+
}
432+
let winId = result.int32Value
433+
if winId != 0 {
434+
if safariWindowIDs[contextId] != nil {
435+
safariWindowIDs[contextId]?.append(Int(winId))
436+
} else {
437+
safariWindowIDs[contextId] = [Int(winId)]
350438
}
439+
return true
351440
}
441+
return true // fallback, even if winId is 0
352442
}
353-
return true
443+
return false
354444
}
355445

356446
private func launchChromeTabs(_ tabs: [BrowserTab], for contextId: UUID) -> Bool {
@@ -425,7 +515,11 @@ class ContextManager: ObservableObject {
425515
let result = appleScript.executeAndReturnError(&error)
426516
let winId = result.int32Value
427517
if winId != 0 {
428-
chromeWindowIDs[contextId] = Int(winId)
518+
if chromeWindowIDs[contextId] != nil {
519+
chromeWindowIDs[contextId]?.append(Int(winId))
520+
} else {
521+
chromeWindowIDs[contextId] = [Int(winId)]
522+
}
429523
return true
430524
} else if let error = error {
431525
print("AppleScript error: \(error)")
@@ -434,26 +528,56 @@ class ContextManager: ObservableObject {
434528
return false
435529
}
436530

437-
/// Close the Chrome window associated with a context (by window ID)
531+
/// Close all Chrome windows associated with a context (by window IDs)
438532
func closeChromeWindowForContext(_ contextId: UUID) {
439-
guard let winId = chromeWindowIDs[contextId] else { return }
533+
guard let winIds = chromeWindowIDs[contextId], !winIds.isEmpty else { return }
534+
let winIdList = winIds.map { String($0) }.joined(separator: ", ")
440535
let script = """
441536
tell application \"Google Chrome\"
442-
if (exists window id \(winId)) then
443-
close window id \(winId)
444-
end if
537+
repeat with wid in {\(winIdList)}
538+
if (exists window id wid) then
539+
try
540+
close window id wid
541+
end try
542+
end if
543+
end repeat
445544
end tell
446545
"""
447546
if let appleScript = NSAppleScript(source: script) {
448547
var error: NSDictionary? = nil
449548
appleScript.executeAndReturnError(&error)
450549
if let error = error {
451-
print("Failed to close Chrome window: \(error)")
550+
print("Failed to close Chrome window(s): \(error)")
452551
}
453552
}
454553
chromeWindowIDs.removeValue(forKey: contextId)
455554
}
456555

556+
/// Close the Safari windows associated with a context (by window IDs)
557+
private func closeSafariWindowsForContext(_ contextId: UUID) {
558+
guard let winIds = safariWindowIDs[contextId], !winIds.isEmpty else { return }
559+
let winIdList = winIds.map { String($0) }.joined(separator: ", ")
560+
let script = """
561+
tell application \"Safari\"
562+
repeat with wid in {\(winIdList)}
563+
if (exists window id wid) then
564+
try
565+
close window id wid
566+
end try
567+
end if
568+
end repeat
569+
end tell
570+
"""
571+
if let appleScript = NSAppleScript(source: script) {
572+
var error: NSDictionary? = nil
573+
appleScript.executeAndReturnError(&error)
574+
if let error = error {
575+
print("Failed to close Safari window(s): \(error)")
576+
}
577+
}
578+
safariWindowIDs.removeValue(forKey: contextId)
579+
}
580+
457581
private func launchContextTerminals(_ context: Context) {
458582
for session in context.terminalSessions {
459583
let commandToRun: String

Flitro/Permissions/PermissionsManager.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
import Foundation
22
import AppKit
33
import ApplicationServices
4+
import Combine
45

56
/// Manager class responsible for handling accessibility
67
class PermissionsManager: ObservableObject {
78
static let shared = PermissionsManager()
89

910
@Published var hasAccessibilityPermission = false
1011

12+
private var cancellable: AnyCancellable?
13+
1114
private init() {
1215
checkPermissions()
16+
// Listen for app activation to refresh permissions
17+
cancellable = NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
18+
.sink { [weak self] _ in
19+
self?.checkPermissions()
20+
}
1321
}
1422

1523
/// Check current permission status

Flitro/Sidebar/ContextSidebarView.swift

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,24 +65,26 @@ struct ContextSidebarView: View {
6565
.frame(maxWidth: .infinity, alignment: .leading)
6666

6767
// Permissions button at the bottom
68-
VStack(spacing: 0) {
69-
Divider()
70-
Button(action: {
71-
permissionsManager.showPermissionDialog()
72-
}) {
73-
HStack {
74-
Image(systemName: permissionsManager.hasAllPermissions ? "checkmark.shield" : "exclamationmark.shield")
75-
.foregroundColor(permissionsManager.hasAllPermissions ? .green : .orange)
76-
Text(permissionsManager.permissionStatusMessage)
77-
.font(.caption)
78-
Spacer()
68+
if !permissionsManager.hasAllPermissions {
69+
VStack(spacing: 0) {
70+
Divider()
71+
Button(action: {
72+
permissionsManager.showPermissionDialog()
73+
}) {
74+
HStack {
75+
Image(systemName: permissionsManager.hasAllPermissions ? "checkmark.shield" : "exclamationmark.shield")
76+
.foregroundColor(permissionsManager.hasAllPermissions ? .green : .orange)
77+
Text(permissionsManager.permissionStatusMessage)
78+
.font(.caption)
79+
Spacer()
80+
}
81+
.padding(.horizontal, 12)
82+
.padding(.vertical, 8)
7983
}
80-
.padding(.horizontal, 12)
81-
.padding(.vertical, 8)
84+
.buttonStyle(.plain)
85+
.background(Color(NSColor.controlBackgroundColor))
86+
.help("Configure permissions")
8287
}
83-
.buttonStyle(.plain)
84-
.background(Color(NSColor.controlBackgroundColor))
85-
.help("Configure accessibility and automation permissions")
8688
}
8789
}
8890
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)

0 commit comments

Comments
 (0)