diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift index de12c6e1..d3deab21 100644 --- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift +++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift @@ -26,7 +26,6 @@ struct DesktopApp: App { SettingsView() .environmentObject(appDelegate.vpn) .environmentObject(appDelegate.state) - .environmentObject(appDelegate.helper) .environmentObject(appDelegate.autoUpdater) } .windowResizability(.contentSize) @@ -48,13 +47,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { let fileSyncDaemon: MutagenDaemon let urlHandler: URLHandler let notifDelegate: NotifDelegate - let helper: HelperService let autoUpdater: UpdaterService override init() { notifDelegate = NotifDelegate() vpn = CoderVPNService() - helper = HelperService() autoUpdater = UpdaterService() let state = AppState(onChange: vpn.configureTunnelProviderProtocol) vpn.onStart = { diff --git a/Coder-Desktop/Coder-Desktop/HelperService.swift b/Coder-Desktop/Coder-Desktop/HelperService.swift deleted file mode 100644 index 17bdc72a..00000000 --- a/Coder-Desktop/Coder-Desktop/HelperService.swift +++ /dev/null @@ -1,117 +0,0 @@ -import os -import ServiceManagement - -// Whilst the GUI app installs the helper, the System Extension communicates -// with it over XPC -@MainActor -class HelperService: ObservableObject { - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperService") - let plistName = "com.coder.Coder-Desktop.Helper.plist" - @Published var state: HelperState = .uninstalled { - didSet { - logger.info("helper daemon state set: \(self.state.description, privacy: .public)") - } - } - - init() { - update() - } - - func update() { - let daemon = SMAppService.daemon(plistName: plistName) - state = HelperState(status: daemon.status) - } - - func install() { - let daemon = SMAppService.daemon(plistName: plistName) - do { - try daemon.register() - } catch let error as NSError { - self.state = .failed(.init(error: error)) - } catch { - state = .failed(.unknown(error.localizedDescription)) - } - state = HelperState(status: daemon.status) - } - - func uninstall() { - let daemon = SMAppService.daemon(plistName: plistName) - do { - try daemon.unregister() - } catch let error as NSError { - self.state = .failed(.init(error: error)) - } catch { - state = .failed(.unknown(error.localizedDescription)) - } - state = HelperState(status: daemon.status) - } -} - -enum HelperState: Equatable { - case uninstalled - case installed - case requiresApproval - case failed(HelperError) - - var description: String { - switch self { - case .uninstalled: - "Uninstalled" - case .installed: - "Installed" - case .requiresApproval: - "Requires Approval" - case let .failed(error): - "Failed: \(error.localizedDescription)" - } - } - - init(status: SMAppService.Status) { - self = switch status { - case .notRegistered: - .uninstalled - case .enabled: - .installed - case .requiresApproval: - .requiresApproval - case .notFound: - // `Not found`` is the initial state, if `register` has never been called - .uninstalled - @unknown default: - .failed(.unknown("Unknown status: \(status)")) - } - } -} - -enum HelperError: Error, Equatable { - case alreadyRegistered - case launchDeniedByUser - case invalidSignature - case unknown(String) - - init(error: NSError) { - self = switch error.code { - case kSMErrorAlreadyRegistered: - .alreadyRegistered - case kSMErrorLaunchDeniedByUser: - .launchDeniedByUser - case kSMErrorInvalidSignature: - .invalidSignature - default: - .unknown(error.localizedDescription) - } - } - - var localizedDescription: String { - switch self { - case .alreadyRegistered: - "Already registered" - case .launchDeniedByUser: - "Launch denied by user" - case .invalidSignature: - "Invalid signature" - case let .unknown(message): - message - } - } -} diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift deleted file mode 100644 index 838f4587..00000000 --- a/Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift +++ /dev/null @@ -1,10 +0,0 @@ -import LaunchAtLogin -import SwiftUI - -struct ExperimentalTab: View { - var body: some View { - Form { - HelperSection() - }.formStyle(.grouped) - } -} diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift deleted file mode 100644 index 66fdc534..00000000 --- a/Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift +++ /dev/null @@ -1,82 +0,0 @@ -import LaunchAtLogin -import ServiceManagement -import SwiftUI - -struct HelperSection: View { - var body: some View { - Section { - HelperButton() - Text(""" - Coder Connect executes a dynamic library downloaded from the Coder deployment. - Administrator privileges are required when executing a copy of this library for the first time. - Without this helper, these are granted by the user entering their password. - With this helper, this is done automatically. - This is useful if the Coder deployment updates frequently. - - Coder Desktop will not execute code unless it has been signed by Coder. - """) - .font(.subheadline) - .foregroundColor(.secondary) - } - } -} - -struct HelperButton: View { - @EnvironmentObject var helperService: HelperService - - var buttonText: String { - switch helperService.state { - case .uninstalled, .failed: - "Install" - case .installed: - "Uninstall" - case .requiresApproval: - "Open Settings" - } - } - - var buttonDescription: String { - switch helperService.state { - case .uninstalled, .installed: - "" - case .requiresApproval: - "Requires approval" - case let .failed(err): - err.localizedDescription - } - } - - func buttonAction() { - switch helperService.state { - case .uninstalled, .failed: - helperService.install() - if helperService.state == .requiresApproval { - SMAppService.openSystemSettingsLoginItems() - } - case .installed: - helperService.uninstall() - case .requiresApproval: - SMAppService.openSystemSettingsLoginItems() - } - } - - var body: some View { - HStack { - Text("Privileged Helper") - Spacer() - Text(buttonDescription) - .foregroundColor(.secondary) - Button(action: buttonAction) { - Text(buttonText) - } - }.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - helperService.update() - }.onAppear { - helperService.update() - } - } -} - -#Preview { - HelperSection().environmentObject(HelperService()) -} diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift index 170d171b..8aac9a0c 100644 --- a/Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift +++ b/Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift @@ -13,11 +13,6 @@ struct SettingsView: View { .tabItem { Label("Network", systemImage: "dot.radiowaves.left.and.right") }.tag(SettingsTab.network) - ExperimentalTab() - .tabItem { - Label("Experimental", systemImage: "gearshape.2") - }.tag(SettingsTab.experimental) - }.frame(width: 600) .frame(maxHeight: 500) .scrollContentBackground(.hidden) @@ -28,5 +23,4 @@ struct SettingsView: View { enum SettingsTab: Int { case general case network - case experimental } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift index 9584ced2..05941b30 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift @@ -10,20 +10,10 @@ struct VPNState: View { Group { switch (vpn.state, state.hasSession) { case (.failed(.systemExtensionError(.needsUserApproval)), _): - VStack { - Text("Awaiting System Extension approval") - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal, Theme.Size.trayInset) - .padding(.vertical, Theme.Size.trayPadding) - .frame(maxWidth: .infinity) - Button { - openSystemExtensionSettings() - } label: { - Text("Approve in System Settings") - } - } + ApprovalRequiredView( + message: "Awaiting System Extension approval", + action: openSystemExtensionSettings + ) case (_, false): Text("Sign in to use Coder Desktop") .font(.body) @@ -32,11 +22,7 @@ struct VPNState: View { VStack { Text("The system VPN requires reconfiguration") .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal, Theme.Size.trayInset) - .padding(.vertical, Theme.Size.trayPadding) - .frame(maxWidth: .infinity) + .vpnStateMessage() Button { state.reconfigure() } label: { @@ -61,11 +47,7 @@ struct VPNState: View { Text("\(vpnErr.description)") .font(.headline) .foregroundColor(.red) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal, Theme.Size.trayInset) - .padding(.vertical, Theme.Size.trayPadding) - .frame(maxWidth: .infinity) + .vpnStateMessage() case (.connected, true): EmptyView() } @@ -73,3 +55,38 @@ struct VPNState: View { .onReceive(inspection.notice) { inspection.visit(self, $0) } // viewInspector } } + +struct ApprovalRequiredView: View { + let message: String + let action: () -> Void + + var body: some View { + VStack { + Text(message) + .foregroundColor(.secondary) + .vpnStateMessage() + Button { + action() + } label: { + Text("Approve in System Settings") + } + } + } +} + +struct VPNStateMessageTextModifier: ViewModifier { + func body(content: Content) -> some View { + content + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, Theme.Size.trayInset) + .padding(.vertical, Theme.Size.trayPadding) + .frame(maxWidth: .infinity) + } +} + +extension View { + func vpnStateMessage() -> some View { + modifier(VPNStateMessageTextModifier()) + } +} diff --git a/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist b/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist index c00eed40..f2309a19 100644 --- a/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist +++ b/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist @@ -4,8 +4,8 @@ Label com.coder.Coder-Desktop.Helper - BundleProgram - Contents/MacOS/com.coder.Coder-Desktop.Helper + Program + /Applications/Coder Desktop.app/Contents/MacOS/com.coder.Coder-Desktop.Helper MachServices diff --git a/Coder-Desktop/project.yml b/Coder-Desktop/project.yml index 166a1570..540bd586 100644 --- a/Coder-Desktop/project.yml +++ b/Coder-Desktop/project.yml @@ -216,6 +216,29 @@ targets: buildToolPlugins: - plugin: SwiftLintBuildToolPlugin package: SwiftLintPlugins + postBuildScripts: + # This is a dependency of the app, not the helper, as it copies the + # helper plist from the app bundle to the system store. + - name: "Upsert Helper for Local Development" + # Only run this script (and prompt for admin) when the helper or any of + # it's frameworks have changed. + inputFiles: + - "$(BUILT_PRODUCTS_DIR)/com.coder.Coder-Desktop.Helper" + - "$(BUILT_PRODUCTS_DIR)/CoderSDK.framework/Versions/A/CoderSDK" + - "$(BUILT_PRODUCTS_DIR)/VPNLib.framework/Versions/A/VPNLib" + outputFiles: + - "$(DERIVED_FILE_DIR)/upsert-helper.stamp" + script: | + if [ -n "${CI}" ]; then + # Skip in CI + exit 0 + fi + /usr/bin/osascript <<'APPLESCRIPT' + do shell script "/bin/bash -c " & quoted form of ((system attribute "SRCROOT") & "/../scripts/upsert-dev-helper.sh") with administrator privileges + APPLESCRIPT + /usr/bin/touch "${DERIVED_FILE_DIR}/upsert-helper.stamp" + basedOnDependencyAnalysis: true + runOnlyWhenInstalling: false Coder-DesktopTests: type: bundle.unit-test @@ -376,4 +399,4 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: "com.coder.Coder-Desktop.Helper" PRODUCT_MODULE_NAME: "$(PRODUCT_NAME:c99extidentifier)" PRODUCT_NAME: "$(PRODUCT_BUNDLE_IDENTIFIER)" - SKIP_INSTALL: YES \ No newline at end of file + SKIP_INSTALL: YES diff --git a/pkgbuild/scripts/postinstall b/pkgbuild/scripts/postinstall index 758776f6..359a9dd4 100755 --- a/pkgbuild/scripts/postinstall +++ b/pkgbuild/scripts/postinstall @@ -2,6 +2,25 @@ RUNNING_MARKER_FILE="/tmp/coder_desktop_running" +LAUNCH_DAEMON_PLIST_SRC="/Applications/Coder Desktop.app/Contents/Library/LaunchDaemons" +LAUNCH_DAEMON_PLIST_DEST="/Library/LaunchDaemons" +LAUNCH_DAEMON_NAME="com.coder.Coder-Desktop.Helper" +LAUNCH_DAEMON_PLIST_NAME="$LAUNCH_DAEMON_NAME.plist" +LAUNCH_DAEMON_BINARY_PATH="/Applications/Coder Desktop.app/Contents/MacOS/com.coder.Coder-Desktop.Helper" + +# Install daemon +# Copy plist into system dir +sudo cp "$LAUNCH_DAEMON_PLIST_SRC"/"$LAUNCH_DAEMON_PLIST_NAME" "$LAUNCH_DAEMON_PLIST_DEST"/"$LAUNCH_DAEMON_PLIST_NAME" +# Set necessary permissions +sudo chmod -R 755 "$LAUNCH_DAEMON_BINARY_PATH" +sudo chmod 644 "$LAUNCH_DAEMON_PLIST_DEST"/"$LAUNCH_DAEMON_PLIST_NAME" +sudo chown root:wheel "$LAUNCH_DAEMON_PLIST_DEST"/"$LAUNCH_DAEMON_PLIST_NAME" + +# Load daemon +sudo launchctl enable "system/$LAUNCH_DAEMON_NAME" || true # Might already be enabled +sudo launchctl bootstrap system "$LAUNCH_DAEMON_PLIST_DEST/$LAUNCH_DAEMON_PLIST_NAME" +sudo launchctl kickstart -k "system/$LAUNCH_DAEMON_NAME" + # Before this script, or the user, opens the app, make sure # Gatekeeper has ingested the notarization ticket. spctl -avvv "/Applications/Coder Desktop.app" diff --git a/pkgbuild/scripts/preinstall b/pkgbuild/scripts/preinstall index d52c1330..5582c635 100755 --- a/pkgbuild/scripts/preinstall +++ b/pkgbuild/scripts/preinstall @@ -1,6 +1,10 @@ #!/usr/bin/env bash RUNNING_MARKER_FILE="/tmp/coder_desktop_running" +LAUNCH_DAEMON_NAME="com.coder.Coder-Desktop.Helper" + +# Stop an existing launch daemon, if it exists +sudo launchctl bootout "system/$LAUNCH_DAEMON_NAME" 2>/dev/null || true rm $RUNNING_MARKER_FILE || true diff --git a/scripts/build.sh b/scripts/build.sh index f6e537a6..e1589dbb 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -206,6 +206,3 @@ echo "$signature" >"$PKG_PATH.sig" # Add dsym to build artifacts (cd "$ARCHIVE_PATH/dSYMs" && zip -9 -r --symlinks "$DSYM_ZIPPED_PATH" ./*) - -# Add zipped app to build artifacts -zip -9 -r --symlinks "$APP_ZIPPED_PATH" "$BUILT_APP_PATH" diff --git a/scripts/update-cask.sh b/scripts/update-cask.sh index 478ea610..7e2a1c5c 100755 --- a/scripts/update-cask.sh +++ b/scripts/update-cask.sh @@ -93,6 +93,7 @@ cask "coder-desktop" do uninstall quit: [ "com.coder.Coder-Desktop", + "com.coder.Coder-Desktop.Helper", "com.coder.Coder-Desktop.VPN", ], login_item: "Coder Desktop" diff --git a/scripts/upsert-dev-helper.sh b/scripts/upsert-dev-helper.sh new file mode 100755 index 00000000..8c61f274 --- /dev/null +++ b/scripts/upsert-dev-helper.sh @@ -0,0 +1,30 @@ +# This script operates like postinstall + preinstall, but for local development +# builds, where the helper is necessary. Instead of looking for +# /Applications/Coder Desktop.app, it looks for +# /Applications/Coder/Coder Desktop.app, which is where the local build is +# installed. + +set -euxo pipefail + +LAUNCH_DAEMON_PLIST_SRC="/Applications/Coder/Coder Desktop.app/Contents/Library/LaunchDaemons" +LAUNCH_DAEMON_PLIST_DEST="/Library/LaunchDaemons" +LAUNCH_DAEMON_NAME="com.coder.Coder-Desktop.Helper" +LAUNCH_DAEMON_PLIST_NAME="$LAUNCH_DAEMON_NAME.plist" +LAUNCH_DAEMON_BINARY_PATH="/Applications/Coder/Coder Desktop.app/Contents/MacOS/com.coder.Coder-Desktop.Helper" + +# Stop an existing launch daemon, if it exists +sudo launchctl bootout "system/$LAUNCH_DAEMON_NAME" 2>/dev/null || true + +# Install daemon +# Copy plist into system dir, with the path corrected to the local build +sed 's|/Applications/Coder Desktop\.app|/Applications/Coder/Coder Desktop.app|g' "$LAUNCH_DAEMON_PLIST_SRC"/"$LAUNCH_DAEMON_PLIST_NAME" | sudo tee "$LAUNCH_DAEMON_PLIST_DEST"/"$LAUNCH_DAEMON_PLIST_NAME" >/dev/null +# Set necessary permissions +sudo chmod -R 755 "$LAUNCH_DAEMON_BINARY_PATH" +sudo chmod 644 "$LAUNCH_DAEMON_PLIST_DEST"/"$LAUNCH_DAEMON_PLIST_NAME" +sudo chown root:wheel "$LAUNCH_DAEMON_PLIST_DEST"/"$LAUNCH_DAEMON_PLIST_NAME" + +# Load daemon +sudo launchctl enable "system/$LAUNCH_DAEMON_NAME" || true # Might already be enabled +sudo launchctl bootstrap system "$LAUNCH_DAEMON_PLIST_DEST/$LAUNCH_DAEMON_PLIST_NAME" +sudo launchctl kickstart -k "system/$LAUNCH_DAEMON_NAME" +