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 Screenshot of background item

-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: +

Screenshot of accessibility permission request +

+ + 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`: + +

Screenshot of extension permission

@@ -51,6 +62,9 @@ As per [GitHub's Terms of Service](https://docs.github.com/en/github/site-policy Screenshot of Xcode Editor GitHub Copilot menu item

+ 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.

Screenshot of sign-in popup @@ -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. + +

+ Screenshot of welcome screen +

+ ## 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"]) + } +}