Skip to content

Commit fb8a557

Browse files
committed
Merge branch 'feature/github-copilot-installation-manager' into develop
2 parents 9eb9ff7 + bed6b1a commit fb8a557

2 files changed

Lines changed: 188 additions & 1 deletion

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import Foundation
2+
import Terminal
3+
4+
public struct GitHubCopilotInstallationManager {
5+
private static var isInstalling = false
6+
7+
public init() {}
8+
9+
public enum InstallationStatus {
10+
case notInstalled
11+
case installed
12+
}
13+
14+
public func checkInstallation() -> InstallationStatus {
15+
guard let urls = try? GitHubCopilotBaseService.createFoldersIfNeeded()
16+
else { return .notInstalled }
17+
let executableFolderURL = urls.executableURL
18+
let binaryURL = executableFolderURL.appendingPathComponent("copilot")
19+
20+
if !FileManager.default.fileExists(atPath: binaryURL.path) {
21+
return .notInstalled
22+
}
23+
24+
return .installed
25+
}
26+
27+
public enum InstallationStep {
28+
case downloading
29+
case uninstalling
30+
case decompressing
31+
case done
32+
}
33+
34+
public enum Error: Swift.Error, LocalizedError {
35+
case isInstalling
36+
case failedToFindLanguageServer
37+
38+
public var errorDescription: String? {
39+
switch self {
40+
case .isInstalling:
41+
return "Language server is installing."
42+
case .failedToFindLanguageServer:
43+
return "Failed to find language server. Please open an issue on GitHub."
44+
}
45+
}
46+
}
47+
48+
public func installLatestVersion() -> AsyncThrowingStream<InstallationStep, Swift.Error> {
49+
AsyncThrowingStream<InstallationStep, Swift.Error> { continuation in
50+
Task {
51+
guard !GitHubCopilotInstallationManager.isInstalling else {
52+
continuation.finish(throwing: Error.isInstalling)
53+
return
54+
}
55+
GitHubCopilotInstallationManager.isInstalling = true
56+
defer { GitHubCopilotInstallationManager.isInstalling = false }
57+
do {
58+
continuation.yield(.downloading)
59+
let urls = try GitHubCopilotBaseService.createFoldersIfNeeded()
60+
let executable = Bundle.main.bundleURL.appendingPathComponent("Contents/Applications/CopilotForXcodeExtensionService.app/Contents/Resources/copilot")
61+
guard FileManager.default.fileExists(atPath: executable.path) else {
62+
throw Error.failedToFindLanguageServer
63+
}
64+
65+
let targetURL = urls.executableURL.appendingPathComponent("copilot")
66+
67+
try FileManager.default.copyItem(
68+
at: executable,
69+
to: targetURL
70+
)
71+
72+
// update permission 755
73+
try FileManager.default.setAttributes(
74+
[.posixPermissions: 0o755],
75+
ofItemAtPath: targetURL.path
76+
)
77+
78+
continuation.yield(.done)
79+
continuation.finish()
80+
} catch {
81+
continuation.finish(throwing: error)
82+
}
83+
}
84+
}
85+
}
86+
87+
public func uninstall() async throws {
88+
guard let urls = try? GitHubCopilotBaseService.createFoldersIfNeeded()
89+
else { return }
90+
let executableFolderURL = urls.executableURL
91+
let binaryURL = executableFolderURL.appendingPathComponent("copilot")
92+
if FileManager.default.fileExists(atPath: binaryURL.path) {
93+
try FileManager.default.removeItem(at: binaryURL)
94+
}
95+
}
96+
}

Core/Sources/HostApp/AccountSettings/CopilotView.swift

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,69 @@ struct CopilotView: View {
1616

1717
init() {}
1818
}
19+
20+
class ViewModel: ObservableObject {
21+
let installationManager = GitHubCopilotInstallationManager()
22+
23+
@Published var installationStatus: GitHubCopilotInstallationManager.InstallationStatus
24+
@Published var installationStep: GitHubCopilotInstallationManager.InstallationStep?
25+
26+
init() {
27+
installationStatus = installationManager.checkInstallation()
28+
}
29+
30+
init(
31+
installationStatus: GitHubCopilotInstallationManager.InstallationStatus,
32+
installationStep: GitHubCopilotInstallationManager.InstallationStep?
33+
) {
34+
assert(isPreview)
35+
self.installationStatus = installationStatus
36+
self.installationStep = installationStep
37+
}
38+
39+
func refreshInstallationStatus() {
40+
Task { @MainActor in
41+
installationStatus = installationManager.checkInstallation()
42+
}
43+
}
44+
45+
func install() async throws {
46+
defer { refreshInstallationStatus() }
47+
do {
48+
for try await step in installationManager.installLatestVersion() {
49+
Task { @MainActor in
50+
self.installationStep = step
51+
}
52+
}
53+
Task {
54+
try await Task.sleep(nanoseconds: 1_000_000_000)
55+
Task { @MainActor in
56+
self.installationStep = nil
57+
}
58+
}
59+
} catch {
60+
Task { @MainActor in
61+
installationStep = nil
62+
}
63+
throw error
64+
}
65+
}
66+
67+
func uninstall() {
68+
Task {
69+
defer { refreshInstallationStatus() }
70+
try await installationManager.uninstall()
71+
Task { @MainActor in
72+
CopilotView.copilotAuthService = nil
73+
}
74+
}
75+
}
76+
}
1977

2078
@Environment(\.openURL) var openURL
2179
@Environment(\.toast) var toast
2280
@StateObject var settings = Settings()
81+
@StateObject var viewModel = ViewModel()
2382

2483
@State var status: GitHubCopilotAccountStatus?
2584
@State var userCode: String?
@@ -33,6 +92,30 @@ struct CopilotView: View {
3392
Self.copilotAuthService = service
3493
return service
3594
}
95+
96+
var installButton: some View {
97+
Button(action: {
98+
Task {
99+
do {
100+
try await viewModel.install()
101+
} catch {
102+
toast(Text(error.localizedDescription), .error)
103+
}
104+
}
105+
}) {
106+
Text("Install")
107+
}
108+
.disabled(viewModel.installationStep != nil)
109+
}
110+
111+
var uninstallButton: some View {
112+
Button(action: {
113+
viewModel.uninstall()
114+
}) {
115+
Text("Uninstall")
116+
}
117+
.disabled(viewModel.installationStep != nil)
118+
}
36119

37120
var body: some View {
38121
HStack {
@@ -66,7 +149,15 @@ struct CopilotView: View {
66149
.foregroundColor(.secondary)
67150

68151
VStack(alignment: .leading) {
69-
Text("Language Server Version: \(version ?? "Loading..")")
152+
HStack {
153+
Text("Language Server Version: \(version ?? "Loading..")")
154+
switch viewModel.installationStatus {
155+
case .notInstalled:
156+
installButton
157+
case .installed:
158+
uninstallButton
159+
}
160+
}
70161
Text("Status: \(status?.description ?? "Loading..")")
71162

72163
HStack(alignment: .center) {

0 commit comments

Comments
 (0)