diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift
index a1cf045..bd7df55 100644
--- a/Copilot for Xcode/App.swift
+++ b/Copilot for Xcode/App.swift
@@ -11,8 +11,14 @@ struct VisualEffect: NSViewRepresentable {
func updateNSView(_ nsView: NSView, context: Context) { }
}
+class AppDelegate: NSObject, NSApplicationDelegate {
+ func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { true }
+}
+
@main
struct CopilotForXcodeApp: App {
+ @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
+
var body: some Scene {
WindowGroup {
TabContainer()
diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift
index fa3074e..3a2db0d 100644
--- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift
@@ -87,7 +87,7 @@ struct SuggestionFeatureDisabledLanguageListView: View {
if settings.suggestionFeatureDisabledLanguageList.isEmpty {
Text("""
Empty
- Disable the language of a file by right clicking the circular widget.
+ Disable the language of a file from the Copilot menu in the status bar.
""")
.multilineTextAlignment(.center)
.padding()
diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift
index 4fd3d55..d55b38d 100644
--- a/Core/Sources/HostApp/GeneralView.swift
+++ b/Core/Sources/HostApp/GeneralView.swift
@@ -29,6 +29,7 @@ struct GeneralView: View {
Spacer().frame(height: 40)
rightsView
.padding(.horizontal, 20)
+ .padding(.bottom, 20)
}
.frame(maxWidth: .infinity)
}
@@ -123,7 +124,8 @@ struct GeneralSettingsView: View {
@StateObject var settings = Settings()
@Environment(\.updateChecker) var updateChecker
@AppStorage(\.realtimeSuggestionToggle) var isCopilotEnabled: Bool
- @State private var shouldPresentInstructionSheet = false
+ @AppStorage(\.extensionPermissionShown) var extensionPermissionShown: Bool
+ @State private var shouldPresentExtensionPermissionAlert = false
@State private var shouldPresentTurnoffSheet = false
let store: StoreOf
@@ -151,17 +153,15 @@ struct GeneralSettingsView: View {
VStack(alignment: .leading) {
let grantedStatus: String = {
guard let granted = store.isAccessibilityPermissionGranted else { return StringConstants.loading }
- return granted ? "Granted" : "Not Granted"
+ return granted ? "Granted" : "Not Granted. Required to run. Click to open System Preferences."
}()
- Text(StringConstants.accessibilityPermissions)
+ Text(StringConstants.accessibilityPermission)
.font(.body)
- Text("\(StringConstants.status) \(grantedStatus) ⓘ")
+ Text("\(StringConstants.status) \(grantedStatus)")
.font(.footnote)
}
Spacer()
-
- Image(systemName: "control")
- .rotationEffect(.degrees(90))
+ Image(systemName: "chevron.right")
}
}
.foregroundStyle(.primary)
@@ -170,23 +170,19 @@ struct GeneralSettingsView: View {
Divider()
HStack {
VStack(alignment: .leading) {
- let grantedStatus: String = {
- guard let granted = store.isAccessibilityPermissionGranted else { return StringConstants.loading }
- return granted ? "Granted" : "Not Granted"
- }()
- Text(StringConstants.extensionPermissions)
+ Text(StringConstants.extensionPermission)
.font(.body)
- Text("\(StringConstants.status) \(grantedStatus) ⓘ")
- .font(.footnote)
- .onTapGesture {
- shouldPresentInstructionSheet = true
- }
+ Text("""
+ Check for GitHub Copilot in Xcode's Editor menu. \
+ Restart Xcode if greyed out.
+ """)
+ .font(.footnote)
}
Spacer()
- Link(destination: URL(string: "x-apple.systempreferences:com.apple.ExtensionsPreferences")!) {
- Image(systemName: "control")
- .rotationEffect(.degrees(90))
- }
+ Image(systemName: "chevron.right")
+ }
+ .onTapGesture {
+ shouldPresentExtensionPermissionAlert = true
}
.foregroundStyle(.primary)
.padding(.horizontal, 8)
@@ -210,11 +206,17 @@ struct GeneralSettingsView: View {
}
}
.padding(.horizontal, 20)
- .sheet(isPresented: $shouldPresentInstructionSheet) {
- } content: {
- InstructionSheet {
- shouldPresentInstructionSheet = false
- }
+ .alert(
+ "Enable Extension Permission",
+ isPresented: $shouldPresentExtensionPermissionAlert
+ ) {
+ Button("Open System Preferences", action: {
+ let url = "x-apple.systempreferences:com.apple.ExtensionsPreferences"
+ NSWorkspace.shared.open(URL(string: url)!)
+ }).keyboardShortcut(.defaultAction)
+ Button("Close", role: .cancel, action: {})
+ } message: {
+ Text("Enable GitHub Copilot under Xcode Source Editor extensions")
}
.alert(isPresented: $shouldPresentTurnoffSheet) {
Alert(
@@ -229,6 +231,11 @@ struct GeneralSettingsView: View {
}
)
}
+ .task {
+ if extensionPermissionShown { return }
+ extensionPermissionShown = true
+ shouldPresentExtensionPermissionAlert = true
+ }
}
}
@@ -314,8 +321,7 @@ struct CopilotConnectionView: View {
.font(.body)
Spacer()
- Image(systemName: "control")
- .rotationEffect(.degrees(90))
+ Image(systemName: "chevron.right")
}
}
.foregroundStyle(.primary)
@@ -327,7 +333,6 @@ struct CopilotConnectionView: View {
}
.padding(.horizontal, 20)
.onAppear {
- store.send(.reloadStatus)
viewModel.checkStatus()
}
}
@@ -345,8 +350,7 @@ struct CopilotConnectionView: View {
Text(StringConstants.copilotDocumentation)
.font(.body)
Spacer()
- Image(systemName: "control")
- .rotationEffect(.degrees(90))
+ Image(systemName: "chevron.right")
}
}
.foregroundStyle(.primary)
@@ -361,8 +365,7 @@ struct CopilotConnectionView: View {
Text(StringConstants.copilotFeedbackForum)
.font(.body)
Spacer()
- Image(systemName: "control")
- .rotationEffect(.degrees(90))
+ Image(systemName: "chevron.right")
}
}
.foregroundStyle(.primary)
diff --git a/Core/Sources/HostApp/InstructionSheet.swift b/Core/Sources/HostApp/InstructionSheet.swift
deleted file mode 100644
index c6e6d9a..0000000
--- a/Core/Sources/HostApp/InstructionSheet.swift
+++ /dev/null
@@ -1,82 +0,0 @@
-import Foundation
-import SwiftUI
-
-struct InstructionSheet: View {
- let closeAction: () -> ()
-
- var body: some View {
- VStack(alignment: .center) {
- Image("copilotIcon")
- .resizable()
- .frame(width: 64, height: 64, alignment: .center)
- .padding(.top, 36)
- .padding(.bottom, 16)
- Text("Extension Permissions")
- .fontWeight(.heavy)
- .font(.system(size: 16))
- Text("To enable permissions in settings:")
- .font(.system(size: 14))
- .padding(.top, 4)
-
- VStack(alignment: .center) {
- HStack {
- ZStack {
- RoundedRectangle(cornerRadius: 3)
- .fill(Color.blue)
- .frame(width: 13, height: 13)
-
- Image(systemName: "checkmark")
- .foregroundColor(.white)
- .font(.system(size: 8, weight: .bold))
- }
-
- Text("Xcode Source Editor")
- .font(.system(size: 12))
-
- Image(systemName: "arrowshape.left.fill")
- .resizable()
- .foregroundColor(Color.red)
- .frame(width: 40, height: 10)
- }
- .frame(height: 25)
- .padding(.horizontal, 12)
-
- HStack {
- Image("copilotIcon")
- .resizable()
- .frame(width: 15, height: 15, alignment: .center)
- Text("GitHub Copilot for Xcode")
- .font(.system(size: 12))
- }
- .frame(height: 25)
- .padding(.horizontal, 8)
- }
- .padding(.vertical, 4)
- .background(Color.blue.opacity(0.1))
- .cornerRadius(5)
- .padding(.vertical, 10)
-
- VStack(spacing: 0) {
- Text("To view Copilot preferences in XCode, path:")
- .font(.system(size: 12))
- .padding(.top, 16)
- Text("Xcode Source Editor > GitHub Copilot")
- .bold()
- .font(.system(size: 12))
- }
- .padding(.horizontal)
-
- Button(action: closeAction, label:{
- Text("Close")
- .foregroundColor(.white)
- .frame(height: 28)
- .frame(maxWidth: .infinity)
- })
- .buttonStyle(.borderedProminent)
- .cornerRadius(5)
- .padding(16)
- .padding(.bottom, 16)
- }
- .frame(width: 300, height: 376)
- }
-}
diff --git a/Core/Sources/HostApp/StringConstants.swift b/Core/Sources/HostApp/StringConstants.swift
index be7976c..ebd04e3 100644
--- a/Core/Sources/HostApp/StringConstants.swift
+++ b/Core/Sources/HostApp/StringConstants.swift
@@ -7,8 +7,8 @@ struct StringConstants {
static let installPreReleases = "Install pre-releases"
static let general = "General"
static let quitCopilot = "Quit GitHub Copilot when Xcode App is closed"
- static let accessibilityPermissions = "Accessibility Permissions"
- static let extensionPermissions = "Extension Permissions"
+ static let accessibilityPermission = "Accessibility Permission"
+ static let extensionPermission = "Extension Permission"
static let status = "Status:"
static let cancel = "Cancel"
static let turnOff = "Turn off"
diff --git a/Core/Sources/XcodeThemeController/PreferenceKey+Theme.swift b/Core/Sources/XcodeThemeController/PreferenceKey+Theme.swift
index 0d46af1..0c69638 100644
--- a/Core/Sources/XcodeThemeController/PreferenceKey+Theme.swift
+++ b/Core/Sources/XcodeThemeController/PreferenceKey+Theme.swift
@@ -17,7 +17,7 @@ public extension UserDefaultPreferenceKeys {
}
var darkXcodeTheme: PreferenceKey> {
- .init(defaultValue: .init(nil), key: "LightXcodeTheme")
+ .init(defaultValue: .init(nil), key: "DarkXcodeTheme")
}
var lastSyncedHighlightJSThemeCreatedAt: PreferenceKey {
diff --git a/Core/Sources/XcodeThemeController/XcodeThemeController.swift b/Core/Sources/XcodeThemeController/XcodeThemeController.swift
index f27cfe3..2f4f0fc 100644
--- a/Core/Sources/XcodeThemeController/XcodeThemeController.swift
+++ b/Core/Sources/XcodeThemeController/XcodeThemeController.swift
@@ -1,13 +1,13 @@
import AppKit
import Foundation
import Highlightr
+import Logger
import XcodeInspector
public class XcodeThemeController {
- var syncTriggerTask: Task?
+ var syncTriggerTask: Task? // to be removed
- public init(syncTriggerTask: Task? = nil) {
- self.syncTriggerTask = syncTriggerTask
+ public init() {
}
public func start() {
@@ -17,9 +17,13 @@ public class XcodeThemeController {
controller: self
)
- syncXcodeThemeIfNeeded()
+ syncXcodeThemeIfNeeded(forceRefresh: true)
+
+ guard syncTriggerTask == nil else {
+ Logger.service.error("XcodeThemeController.start() invoked multiple times.")
+ return
+ }
- syncTriggerTask?.cancel()
syncTriggerTask = Task { [weak self] in
let notifications = NSWorkspace.shared.notificationCenter
.notifications(named: NSWorkspace.didActivateApplicationNotification)
@@ -33,11 +37,19 @@ public class XcodeThemeController {
self.syncXcodeThemeIfNeeded()
}
}
+
+ Timer.scheduledTimer(
+ withTimeInterval: 60,
+ repeats: true
+ ) { [weak self] _ in
+ guard XcodeInspector.shared.activeApplication?.isXcode == true else { return }
+ self?.syncXcodeThemeIfNeeded()
+ }
}
}
extension XcodeThemeController {
- func syncXcodeThemeIfNeeded() {
+ func syncXcodeThemeIfNeeded(forceRefresh: Bool = false) {
guard UserDefaults.shared.value(for: \.syncSuggestionHighlightTheme)
|| UserDefaults.shared.value(for: \.syncPromptToCodeHighlightTheme)
|| UserDefaults.shared.value(for: \.syncChatCodeHighlightTheme)
@@ -59,7 +71,8 @@ extension XcodeThemeController {
syncXcodeThemeIfNeeded(
xcodeThemeName: darkThemeName,
light: false,
- in: directories.themeDirectory
+ in: directories.themeDirectory,
+ forceRefresh: forceRefresh
)
}
@@ -69,7 +82,8 @@ extension XcodeThemeController {
syncXcodeThemeIfNeeded(
xcodeThemeName: lightThemeName,
light: true,
- in: directories.themeDirectory
+ in: directories.themeDirectory,
+ forceRefresh: forceRefresh
)
}
}
@@ -77,15 +91,20 @@ extension XcodeThemeController {
func syncXcodeThemeIfNeeded(
xcodeThemeName: String,
light: Bool,
- in directoryURL: URL
+ in directoryURL: URL,
+ forceRefresh: Bool = false
) {
let targetName = light ? "highlightjs-light" : "highlightjs-dark"
- guard let xcodeThemeURL = locateXcodeTheme(named: xcodeThemeName) else { return }
+ guard let xcodeThemeURL = locateXcodeTheme(named: xcodeThemeName) else {
+ Logger.service.error("Xcode theme not found: \(xcodeThemeName)")
+ return
+ }
let targetThemeURL = directoryURL.appendingPathComponent(targetName)
let lastSyncTimestamp = UserDefaults.shared
.value(for: \.lastSyncedHighlightJSThemeCreatedAt)
let shouldSync = {
+ if forceRefresh { return true }
if light, UserDefaults.shared.value(for: \.lightXcodeTheme) == nil { return true }
if !light, UserDefaults.shared.value(for: \.darkXcodeTheme) == nil { return true }
if light, xcodeThemeName != UserDefaults.shared.value(for: \.lightXcodeThemeName) {
@@ -109,6 +128,7 @@ extension XcodeThemeController {
}()
if shouldSync {
+ Logger.service.info("Syncing Xcode theme: \(xcodeThemeName)")
do {
let theme = try XcodeTheme(fileURL: xcodeThemeURL)
let highlightrTheme = theme.asHighlightJSTheme()
@@ -156,7 +176,7 @@ extension XcodeThemeController {
}
}
} catch {
- print(error.localizedDescription)
+ Logger.service.error("Failed to sync Xcode theme \"\(xcodeThemeName)\": \(error)")
}
}
}
@@ -233,6 +253,7 @@ extension XcodeThemeController {
Bundle.main
.object(forInfoDictionaryKey: "APPLICATION_SUPPORT_FOLDER") as! String
) else {
+ Logger.service.error("Could not determine support directory for Xcode theme synching")
return nil
}
@@ -255,6 +276,7 @@ extension XcodeThemeController {
)
}
} catch {
+ Logger.service.error("Failed to create support directories for Xcode theme synching: \(error)")
return nil
}
diff --git a/Docs/welcome.png b/Docs/welcome.png
new file mode 100644
index 0000000..c40d018
Binary files /dev/null and b/Docs/welcome.png differ
diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift
index a278af1..1b34f72 100644
--- a/ExtensionService/AppDelegate+Menu.swift
+++ b/ExtensionService/AppDelegate+Menu.swift
@@ -1,6 +1,7 @@
import AppKit
import Foundation
import Preferences
+import SuggestionBasic
import XcodeInspector
import Logger
@@ -25,6 +26,10 @@ extension AppDelegate {
.init("toggleCompletionsMenuItem")
}
+ fileprivate var toggleIgnoreLanguageMenuItemIdentifier: NSUserInterfaceItemIdentifier {
+ .init("toggleIgnoreLanguageMenuItem")
+ }
+
fileprivate var copilotStatusMenuItemIdentifier: NSUserInterfaceItemIdentifier {
.init("copilotStatusMenuItem")
}
@@ -88,6 +93,13 @@ extension AppDelegate {
)
toggleCompletions.identifier = toggleCompletionsMenuItemIdentifier;
+ let toggleIgnoreLanguage = NSMenuItem(
+ title: "No Active Document",
+ action: nil,
+ keyEquivalent: ""
+ )
+ toggleIgnoreLanguage.identifier = toggleIgnoreLanguageMenuItemIdentifier;
+
let copilotStatus = NSMenuItem(
title: "Copilot Connection: Checking...",
action: nil,
@@ -111,6 +123,7 @@ extension AppDelegate {
statusBarMenu.addItem(.separator())
statusBarMenu.addItem(checkForUpdate)
statusBarMenu.addItem(toggleCompletions)
+ statusBarMenu.addItem(toggleIgnoreLanguage)
statusBarMenu.addItem(.separator())
statusBarMenu.addItem(copilotStatus)
statusBarMenu.addItem(accessibilityAPIPermission)
@@ -143,6 +156,18 @@ extension AppDelegate: NSMenuDelegate {
toggleCompletions.title = "\(UserDefaults.shared.value(for: \.realtimeSuggestionToggle) ? "Disable" : "Enable") Completions"
}
+ if let toggleLanguage = menu.items.first(where: { item in
+ item.identifier == toggleIgnoreLanguageMenuItemIdentifier
+ }) {
+ if let lang = DisabledLanguageList.shared.activeDocumentLanguage {
+ toggleLanguage.title = "\(DisabledLanguageList.shared.isEnabled(lang) ? "Disable" : "Enable") Completions For \(lang.rawValue)"
+ toggleLanguage.action = #selector(toggleIgnoreLanguage)
+ } else {
+ toggleLanguage.title = "No Active Document"
+ toggleLanguage.action = nil
+ }
+ }
+
if let accessibilityAPIPermission = menu.items.first(where: { item in
item.identifier == accessibilityAPIPermissionMenuItemIdentifier
}) {
@@ -271,6 +296,16 @@ private extension AppDelegate {
}
}
+ @objc func toggleIgnoreLanguage() {
+ guard let lang = DisabledLanguageList.shared.activeDocumentLanguage else { return }
+
+ if DisabledLanguageList.shared.isEnabled(lang) {
+ DisabledLanguageList.shared.disable(lang)
+ } else {
+ DisabledLanguageList.shared.enable(lang)
+ }
+ }
+
@objc func openCopilotDocs() {
if let urlString = Bundle.main.object(forInfoDictionaryKey: "COPILOT_DOCS_URL") as? String {
if let url = URL(string: urlString) {
diff --git a/README.md b/README.md
index 341a98a..0327be5 100644
--- a/README.md
+++ b/README.md
@@ -39,9 +39,20 @@ As per [GitHub's Terms of Service](https://docs.github.com/en/github/site-policy
-1. Two important permissions are required for the application to operate well: `Accessibility` and `Xcode Source Editor Extension`. The first time the application is run these permissions should be requested. You may need to click `Refresh` in the settings if not prompted.
+1. Two permissions are required: `Accessibility` and `Xcode Source Editor Extension`.
+
+ The first time the application is run the `Accessibility` permission should be requested:
+
+
+
+ The `Xcode Source Editor Extension` permission needs to be enabled manually. Click
+ `Extension Permission` from the `Copilot for Xcode` settings to open the
+ System Preferences to the `Extensions` panel. Select `Xcode Source Editor`
+ and enable `GitHub Copilot`:
+
+
@@ -51,6 +62,9 @@ As per [GitHub's Terms of Service](https://docs.github.com/en/github/site-policy
+ Keyboard shortcuts can be set for all menu items in the `Key Bindings`
+ section of Xcode preferences.
+
1. To sign into GitHub Copilot, click the `Sign in` button in the settings application. This will open a browser window and copy a code to the clipboard. Paste the code into the GitHub login page and authorize the application.
@@ -58,6 +72,16 @@ As per [GitHub's Terms of Service](https://docs.github.com/en/github/site-policy
1. To install updates, click `Check for Updates` from the menu item or in the settings application. After installing a new version, Xcode must be restarted to use the new version correctly. New versions can also be installed from `dmg` files downloaded from the releases page. When installing a new version via `dmg`, the application must be run manually the first time to accept the downloaded from the internet warning.
+1. To avoid confusion, we recommend disabling `Predictive code completion` under
+ `Xcode` > `Preferences` > `Text Editing` > `Editing`.
+
+1. Press `tab` to accept the first line of a suggestion, hold `option` to view
+ the full suggestion, and press `option` + `tab` to accept the full suggestion.
+
+
+
+
+
## License
This project is licensed under the terms of the MIT open source license. Please
diff --git a/Server/package-lock.json b/Server/package-lock.json
index 936d91a..72de03f 100644
--- a/Server/package-lock.json
+++ b/Server/package-lock.json
@@ -8,13 +8,13 @@
"name": "@github/copilot-xcode",
"version": "0.0.1",
"dependencies": {
- "@github/copilot-language-server": "^1.235.0"
+ "@github/copilot-language-server": "^1.236.0"
}
},
"node_modules/@github/copilot-language-server": {
- "version": "1.235.0",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.235.0.tgz",
- "integrity": "sha512-QvBoh0qx9yBPBKxxfL2oWxrL5Kl4scniB7QpUJmIkudagc/23epZIo1fZXwHeYYzmnmOpjMATE5PVOieokPXWA==",
+ "version": "1.236.0",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.236.0.tgz",
+ "integrity": "sha512-r9gKmWrfvDNN2epQZ5IBrsIZyIO7Odk+A2n/Y+2NHI1ZIfM60RP1p7DrFFwTTVaaodVpLZNIzFfaetW/68voug==",
"bin": {
"copilot-language-server": "dist/language-server.js"
}
diff --git a/Server/package.json b/Server/package.json
index 32e9d20..5128126 100644
--- a/Server/package.json
+++ b/Server/package.json
@@ -4,6 +4,6 @@
"description": "Package for downloading @github/copilot-language-server",
"private": true,
"dependencies": {
- "@github/copilot-language-server": "^1.235.0"
+ "@github/copilot-language-server": "^1.236.0"
}
}
diff --git a/Tool/Sources/Logger/FileLogger.swift b/Tool/Sources/Logger/FileLogger.swift
new file mode 100644
index 0000000..85f85f9
--- /dev/null
+++ b/Tool/Sources/Logger/FileLogger.swift
@@ -0,0 +1,167 @@
+import Foundation
+import System
+
+final class FileLogger {
+ private let timestampFormat = Date.ISO8601FormatStyle.iso8601
+ .year()
+ .month()
+ .day()
+ .timeZone(separator: .omitted).time(includingFractionalSeconds: true)
+ private let pid = "\(ProcessInfo.processInfo.processIdentifier)"
+ private static let implementation = FileLoggerImplementation()
+
+ private func timestamp() -> String {
+ return Date().formatted(timestampFormat)
+ }
+
+ public func log(level: LogLevel, category: String, message: String) {
+ let log = "[\(timestamp())] [\(level)] [\(category)] [\(pid)] \(message)\(message.hasSuffix("\n") ? "" : "\n")"
+
+ Task {
+ await FileLogger.implementation.logToFile(log)
+ }
+ }
+}
+
+actor FileLoggerImplementation {
+ private let logBaseName = "github-copilot-for-xcode"
+ private let logExtension = "log"
+ private let maxLogSize = 5_000_000
+ private let logOverflowLimit = 5_000_000 * 2
+ private let maxLogs = 10
+ private let maxLockTime = 3_600 // 1 hour
+
+ private let logDir: FilePath
+ private let logName: String
+ private let lockFilePath: FilePath
+ private var logStream: OutputStream?
+ private var logHandle: FileHandle?
+
+ public init() {
+ logDir = FilePath(stringLiteral: NSHomeDirectory())
+ .appending("Library")
+ .appending("Logs")
+ .appending("GitHubCopilot")
+ logName = "\(logBaseName).\(logExtension)"
+ lockFilePath = logDir.appending(logName + ".lock")
+ }
+
+ public func logToFile(_ log: String) {
+ if let stream = logAppender() {
+ let data = [UInt8](log.utf8)
+ stream.write(data, maxLength: data.count)
+ }
+ }
+
+ private func logAppender() -> OutputStream? {
+ if logStream == nil {
+ reopenLogFile()
+ }
+
+ if rotateIfNeeded() > logOverflowLimit {
+ return nil // do not exceed the overflow limit
+ }
+
+ return logStream
+ }
+
+ private func reopenLogFile() {
+ if !FileManager.default.fileExists(atPath: logDir.string) {
+ let success: ()? = try? FileManager.default.createDirectory(atPath: logDir.string, withIntermediateDirectories: true)
+ guard success != nil else { return }
+ }
+
+ let fileName = logDir.appending(logName).string
+ logStream = OutputStream(toFileAtPath: fileName, append: true)
+ logStream?.open()
+
+ logHandle = FileHandle(forReadingAtPath: fileName)
+ }
+
+ private func logSize() -> UInt64{
+ return logHandle?.seekToEndOfFile() ?? 0
+ }
+
+ /// @returns The resulting size of the log file
+ private func rotateIfNeeded() -> UInt64 {
+ let size = logSize()
+
+ if size > maxLogSize {
+ rotateLogs()
+ return logSize() // return the new size of the log file
+ }
+
+ return size
+ }
+
+ private func rotateLogs() {
+ // attempt to acquire a lock for rotating logs
+ let fd = try? FileDescriptor.open(
+ lockFilePath,
+ .readWrite,
+ options: .init([.create, .exclusiveCreate]),
+ permissions: .init(rawValue: 0o666)
+ )
+ guard fd != nil else {
+ // if we can't get the lock, another process is already rotating
+ checkLockValidity() // prevents stale locks
+ return // write to the existing log while rotation is happening
+ }
+
+ defer {
+ try? fd?.close()
+ try? FileManager.default.removeItem(atPath: lockFilePath.string)
+ }
+
+ // check the log size again. if it's under the limit, another process already rotated the logs
+ let fileName = logDir.appending(logName).string
+ let attributes = try? FileManager.default.attributesOfItem(atPath: fileName)
+ let size = (attributes?[FileAttributeKey.size] ?? 0) as! Int
+
+ if (size > maxLogSize) {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyyMMddHHmmss"
+ let archiveName = "\(logBaseName)-\(formatter.string(from: Date())).\(logExtension)"
+ let newName = logDir.appending(archiveName).string
+
+ // moving the log file does not affect any open file handles. they continue writing to the new location.
+ try? FileManager.default.moveItem(atPath: fileName, toPath: newName)
+
+ cleanupOldLogs()
+ }
+
+ reopenLogFile()
+ }
+
+ /// Note: This is only safe to call if the caller has already obtained a lock on the log directory
+ private func cleanupOldLogs() {
+ let logFiles = try? FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: logDir.string), includingPropertiesForKeys: nil)
+ .filter { $0.pathExtension == logExtension && $0.lastPathComponent != logName }
+ .sorted { $0.lastPathComponent > $1.lastPathComponent }
+
+ if let oldLogFiles = logFiles, oldLogFiles.count > maxLogs {
+ for fileURL in oldLogFiles[maxLogs...] {
+ try? FileManager.default.removeItem(at: fileURL)
+ }
+ }
+ }
+
+ /// Checks the lock file's creation time and removes it if it is stale.
+ ///
+ /// If a process hangs or crashes while rotating logs, the lock file will
+ /// be left behind, preventing other processes from rotating logs. To
+ /// prevent this, an lock file older than the lock limit (1 hour) is
+ /// considered stale and removed.
+ ///
+ /// The pending log entry will still be written to the existing log, but
+ /// by removing the lock file, rotation will resume the next time an entry
+ /// is logged.
+ private func checkLockValidity() {
+ let attributes = try? FileManager.default.attributesOfItem(atPath: lockFilePath.string)
+ let ctime = (attributes?[FileAttributeKey.creationDate] ?? NSDate()) as! NSDate
+
+ if ctime.timeIntervalSinceNow < -TimeInterval(maxLockTime) {
+ try? FileManager.default.removeItem(atPath: lockFilePath.string)
+ }
+ }
+}
diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift
index f38243f..649e504 100644
--- a/Tool/Sources/Logger/Logger.swift
+++ b/Tool/Sources/Logger/Logger.swift
@@ -11,6 +11,7 @@ public final class Logger {
private let subsystem: String
private let category: String
private let osLog: OSLog
+ private let fileLogger = FileLogger()
public static let service = Logger(category: "Service")
public static let ui = Logger(category: "UI")
@@ -52,6 +53,7 @@ public final class Logger {
}
os_log("%{public}@", log: osLog, type: osLogType, message as CVarArg)
+ fileLogger.log(level: level, category: category, message: message)
}
public func debug(
diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift
index eb1b3b7..4772feb 100644
--- a/Tool/Sources/Preferences/Keys.swift
+++ b/Tool/Sources/Preferences/Keys.swift
@@ -111,6 +111,11 @@ public struct UserDefaultPreferenceKeys {
defaultValue: false,
key: "HideIntro"
)
+
+ public let extensionPermissionShown = PreferenceKey(
+ defaultValue: false,
+ key: "ExtensionPermissionShown"
+ )
}
// MARK: - Prompt to Code
diff --git a/Tool/Sources/XcodeInspector/DisabledLanguageList.swift b/Tool/Sources/XcodeInspector/DisabledLanguageList.swift
new file mode 100644
index 0000000..ce72356
--- /dev/null
+++ b/Tool/Sources/XcodeInspector/DisabledLanguageList.swift
@@ -0,0 +1,40 @@
+import Foundation
+import Preferences
+import SuggestionBasic
+
+public class DisabledLanguageList {
+ public static let shared = DisabledLanguageList()
+
+ private init() {}
+
+ public var activeDocumentLanguage: CodeLanguage? {
+ let activeURL = XcodeInspector.shared.activeDocumentURL
+ return activeURL.map(languageIdentifierFromFileURL)
+ }
+
+ public var list: [String] {
+ UserDefaults.shared.value(for: \.suggestionFeatureDisabledLanguageList)
+ }
+
+ public func isEnabled(_ language: CodeLanguage) -> Bool {
+ return !list.contains(language.rawValue)
+ }
+
+ public func enable(_ language: CodeLanguage) {
+ UserDefaults.shared.set(
+ list.filter { $0 != language.rawValue },
+ for: \.suggestionFeatureDisabledLanguageList
+ )
+ }
+
+ public func disable(_ language: CodeLanguage) {
+ let currentList = list
+
+ if !currentList.contains(language.rawValue) {
+ UserDefaults.shared.set(
+ currentList + [language.rawValue],
+ for: \.suggestionFeatureDisabledLanguageList
+ )
+ }
+ }
+}
diff --git a/Tool/Tests/XcodeInspectorTests/DisabledLanguageListTests.swift b/Tool/Tests/XcodeInspectorTests/DisabledLanguageListTests.swift
new file mode 100644
index 0000000..8bc20db
--- /dev/null
+++ b/Tool/Tests/XcodeInspectorTests/DisabledLanguageListTests.swift
@@ -0,0 +1,56 @@
+import Preferences
+import SuggestionBasic
+import XCTest
+import XcodeInspector
+
+public class DisabledLanguageListTests: XCTestCase {
+
+ var savedDisabledList: [String] = []
+
+ public override func setUp() {
+ savedDisabledList = UserDefaults.shared.value(for: \.suggestionFeatureDisabledLanguageList)
+ UserDefaults.shared.set(["yaml", "plaintext"], for: \.suggestionFeatureDisabledLanguageList)
+ }
+
+ public override func tearDown() {
+ UserDefaults.shared.set(savedDisabledList, for: \.suggestionFeatureDisabledLanguageList)
+ }
+
+ // MARK: - isEnabled
+
+ public func testIsEnabled_ReturnsTrue_ForLanguageNotOnDisabledList() {
+ XCTAssertTrue(DisabledLanguageList.shared.isEnabled(.builtIn(.swift)))
+ }
+
+ public func testIsEnabled_ReturnsFalse_ForLanguageOnDisabledList() {
+ XCTAssertFalse(DisabledLanguageList.shared.isEnabled(.plaintext))
+ }
+
+ // MARK: - enable
+
+ public func testEnable_RemovesLanguageFromDisabledList() {
+ DisabledLanguageList.shared.enable(.plaintext)
+
+ XCTAssertEqual(DisabledLanguageList.shared.list, ["yaml"])
+ }
+
+ public func testEnable_IgnoresLanguageNotOnDisabledList() {
+ DisabledLanguageList.shared.enable(.builtIn(.swift))
+
+ XCTAssertEqual(DisabledLanguageList.shared.list, ["yaml", "plaintext"])
+ }
+
+ // MARK: - disable
+
+ public func testEnable_AddsLanguageToDisabledList() {
+ DisabledLanguageList.shared.disable(.builtIn(.scala))
+
+ XCTAssertEqual(DisabledLanguageList.shared.list, ["yaml", "plaintext", "scala"])
+ }
+
+ public func testEnable_IgnoresLanguageOnDisabledList() {
+ DisabledLanguageList.shared.disable(.plaintext)
+
+ XCTAssertEqual(DisabledLanguageList.shared.list, ["yaml", "plaintext"])
+ }
+}