Skip to content

feat: support user-supplied literal headers #24

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jan 23, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 122 additions & 35 deletions Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 1 addition & 9 deletions Coder Desktop/Coder Desktop/About.swift
Original file line number Diff line number Diff line change
@@ -32,15 +32,7 @@ enum About {

@MainActor
static func open() {
#if compiler(>=5.9) && canImport(AppKit)
if #available(macOS 14, *) {
NSApp.activate()
} else {
NSApp.activate(ignoringOtherApps: true)
}
#else
NSApp.activate(ignoringOtherApps: true)
#endif
appActivate()
NSApp.orderFrontStandardAboutPanel(options: [
.credits: credits,
])
15 changes: 14 additions & 1 deletion Coder Desktop/Coder Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
@@ -14,6 +14,11 @@ struct DesktopApp: App {
LoginForm<PreviewSession>().environmentObject(appDelegate.session)
}
.windowResizability(.contentSize)
SwiftUI.Settings { SettingsView<PreviewVPN>()
.environmentObject(appDelegate.vpn)
.environmentObject(appDelegate.settings)
}
.windowResizability(.contentSize)
}
}

@@ -22,10 +27,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private var menuBarExtra: FluidMenuBarExtra?
let vpn: PreviewVPN
let session: PreviewSession
let settings: Settings

override init() {
// TODO: Replace with real implementations
// TODO: Replace with real implementation
vpn = PreviewVPN()
settings = Settings()
session = PreviewSession()
}

@@ -34,6 +41,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
VPNMenu<PreviewVPN, PreviewSession>().frame(width: 256)
.environmentObject(self.vpn)
.environmentObject(self.session)
.environmentObject(self.settings)
}
}

@@ -49,3 +57,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
false
}
}

@MainActor
func appActivate() {
NSApp.activate()
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import CoderSDK
import Foundation
import KeychainAccess
import NetworkExtension
import SwiftUI

protocol Session: ObservableObject {
var hasSession: Bool { get }
@@ -89,3 +91,47 @@ class SecureSession: ObservableObject, Session {
static let sessionToken = "sessionToken"
}
}

class Settings: ObservableObject {
private let store: UserDefaults
@AppStorage(Keys.useLiteralHeaders) var useLiteralHeaders = false

@Published var literalHeaders: [LiteralHeader] {
didSet {
try? store.set(JSONEncoder().encode(literalHeaders), forKey: Keys.literalHeaders)
}
}

init(store: UserDefaults = .standard) {
self.store = store
_literalHeaders = Published(
initialValue: store.data(
forKey: Keys.literalHeaders
).flatMap { try? JSONDecoder().decode([LiteralHeader].self, from: $0) } ?? []
)
}

enum Keys {
static let useLiteralHeaders = "UseLiteralHeaders"
static let literalHeaders = "LiteralHeaders"
}
}

struct LiteralHeader: Hashable, Identifiable, Equatable, Codable {
var header: String
var value: String
var id: String {
"\(header):\(value)"
}

init(header: String, value: String) {
self.header = header
self.value = value
}
}

extension LiteralHeader {
func toSDKHeader() -> HTTPHeader {
return .init(header: header, value: value)
}
}
3 changes: 2 additions & 1 deletion Coder Desktop/Coder Desktop/Views/LoginForm.swift
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import SwiftUI

struct LoginForm<S: Session>: View {
@EnvironmentObject var session: S
@EnvironmentObject var settings: Settings
@Environment(\.dismiss) private var dismiss

@State private var baseAccessURL: String = ""
@@ -68,7 +69,7 @@ struct LoginForm<S: Session>: View {
}
loading = true
defer { loading = false }
let client = Client(url: url, token: sessionToken)
let client = Client(url: url, token: sessionToken, headers: settings.literalHeaders.map { $0.toSDKHeader() })
do {
_ = try await client.user("me")
} catch {
16 changes: 16 additions & 0 deletions Coder Desktop/Coder Desktop/Views/Settings/GeneralTab.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import LaunchAtLogin
import SwiftUI

struct GeneralTab: View {
var body: some View {
Form {
Section {
LaunchAtLogin.Toggle("Launch at Login")
}
}.formStyle(.grouped)
}
}

#Preview {
GeneralTab()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import SwiftUI

struct LiteralHeaderModal: View {
var existingHeader: LiteralHeader?

@EnvironmentObject var settings: Settings
@Environment(\.dismiss) private var dismiss

@State private var header: String = ""
@State private var value: String = ""

var body: some View {
VStack(spacing: 0) {
Form {
Section {
TextField("Header", text: $header)
TextField("Value", text: $value)
}
}.formStyle(.grouped).scrollDisabled(true).padding(.horizontal)
Divider()
HStack {
Spacer()
Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
Button(existingHeader == nil ? "Add" : "Save", action: submit)
.keyboardShortcut(.defaultAction)
}.padding(20)
}.onAppear {
if let existingHeader {
self.header = existingHeader.header
self.value = existingHeader.value
}
}
}

func submit() {
defer { dismiss() }
if let existingHeader {
settings.literalHeaders.removeAll { $0 == existingHeader }
}
let newHeader = LiteralHeader(header: header, value: value)
if !settings.literalHeaders.contains(newHeader) {
settings.literalHeaders.append(newHeader)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import SwiftUI

struct LiteralHeadersSection<VPN: VPNService>: View {
@EnvironmentObject var vpn: VPN
@EnvironmentObject var settings: Settings

@State private var selectedHeader: LiteralHeader.ID?
@State private var editingHeader: LiteralHeader?
@State private var addingNewHeader = false

let inspection = Inspection<Self>()

var body: some View {
Section {
Toggle(isOn: settings.$useLiteralHeaders) {
Text("HTTP Headers")
Text("When enabled, these headers will be included on all outgoing HTTP requests.")
if vpn.state != .disabled { Text("Cannot be modified while Coder VPN is enabled.") }
}
.controlSize(.large)

Table(settings.literalHeaders, selection: $selectedHeader) {
TableColumn("Header", value: \.header)
TableColumn("Value", value: \.value)
}.opacity(settings.useLiteralHeaders ? 1 : 0.5)
.frame(minWidth: 400, minHeight: 200)
.padding(.bottom, 25)
.overlay(alignment: .bottom) {
VStack(alignment: .leading, spacing: 0) {
Divider()
HStack(spacing: 0) {
Button {
addingNewHeader = true
} label: {
Image(systemName: "plus")
.frame(width: 24, height: 24)
}
Divider()
Button {
settings.literalHeaders.removeAll { $0.id == selectedHeader }
selectedHeader = nil
} label: {
Image(systemName: "minus")
.frame(width: 24, height: 24)
}.disabled(selectedHeader == nil)
}
.buttonStyle(.borderless)
}
.background(.primary.opacity(0.04))
.fixedSize(horizontal: false, vertical: true)
}
.background(.primary.opacity(0.04))
.contextMenu(forSelectionType: LiteralHeader.ID.self, menu: { _ in },
primaryAction: { selectedHeaders in
if let firstHeader = selectedHeaders.first {
editingHeader = settings.literalHeaders.first(where: { $0.id == firstHeader })
}
})
.disabled(!settings.useLiteralHeaders)
}
.sheet(isPresented: $addingNewHeader) {
LiteralHeaderModal()
}
.sheet(item: $editingHeader) { header in
LiteralHeaderModal(existingHeader: header)
}.onTapGesture {
selectedHeader = nil
}.disabled(vpn.state != .disabled)
.onReceive(inspection.notice) { self.inspection.visit(self, $0) } // ViewInspector
}
}
14 changes: 14 additions & 0 deletions Coder Desktop/Coder Desktop/Views/Settings/NetworkTab.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import SwiftUI

struct NetworkTab<VPN: VPNService>: View {
var body: some View {
Form {
LiteralHeadersSection<VPN>()
}
.formStyle(.grouped)
}
}

#Preview {
NetworkTab<PreviewVPN>()
}
26 changes: 26 additions & 0 deletions Coder Desktop/Coder Desktop/Views/Settings/Settings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import SwiftUI

struct SettingsView<VPN: VPNService>: View {
@AppStorage("SettingsSelectedIndex") private var selection: SettingsTab = .general

var body: some View {
TabView(selection: $selection) {
GeneralTab()
.tabItem {
Label("General", systemImage: "gearshape")
}.tag(SettingsTab.general)
NetworkTab<VPN>()
.tabItem {
Label("Network", systemImage: "dot.radiowaves.left.and.right")
}.tag(SettingsTab.network)
}.frame(width: 600)
.frame(maxHeight: 500)
.scrollContentBackground(.hidden)
.fixedSize()
}
}

enum SettingsTab: Int {
case general
case network
}
1 change: 1 addition & 0 deletions Coder Desktop/Coder Desktop/Views/Util.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Combine
import SwiftUI

// This is required for inspecting stateful views
final class Inspection<V> {
9 changes: 9 additions & 0 deletions Coder Desktop/Coder Desktop/Views/VPNMenu.swift
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import SwiftUI
struct VPNMenu<VPN: VPNService, S: Session>: View {
@EnvironmentObject var vpn: VPN
@EnvironmentObject var session: S
@Environment(\.openSettings) private var openSettings

let inspection = Inspection<Self>()

@@ -21,6 +22,8 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
)) {
Text("CoderVPN")
.frame(maxWidth: .infinity, alignment: .leading)
.font(.body.bold())
.foregroundColor(.primary)
}.toggleStyle(.switch)
.disabled(vpnDisabled)
}
@@ -50,6 +53,12 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
TrayDivider()
}
AuthButton<VPN, S>()
Button {
openSettings()
appActivate()
} label: {
ButtonRowView { Text("Settings") }
}.buttonStyle(.plain)
Button {
About.open()
} label: {
10 changes: 1 addition & 9 deletions Coder Desktop/Coder Desktop/Windows.swift
Original file line number Diff line number Diff line change
@@ -8,15 +8,7 @@ enum Windows: String {
extension OpenWindowAction {
// Type-safe wrapper for opening windows that also focuses the new window
func callAsFunction(id: Windows) {
#if compiler(>=5.9) && canImport(AppKit)
if #available(macOS 14, *) {
NSApp.activate()
} else {
NSApp.activate(ignoringOtherApps: true)
}
#else
NSApp.activate(ignoringOtherApps: true)
#endif
appActivate()
callAsFunction(id: id.rawValue)
// The arranging behaviour is flakey without this
NSApp.arrangeInFront(nil)
48 changes: 48 additions & 0 deletions Coder Desktop/Coder DesktopTests/LiteralHeadersSettingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
@testable import Coder_Desktop
import SwiftUI
import Testing
import ViewInspector

@MainActor
@Suite(.timeLimit(.minutes(1)))
struct LiteralHeadersSettingTests {
let vpn: MockVPNService
let sut: LiteralHeadersSection<MockVPNService>
let view: any View

init() {
vpn = MockVPNService()
sut = LiteralHeadersSection<MockVPNService>()
let store = UserDefaults(suiteName: #file)!
store.removePersistentDomain(forName: #file)
view = sut.environmentObject(vpn).environmentObject(Settings(store: store))
}

@Test
func testToggleDisabledWhenVPNEnabled() async throws {
vpn.state = .connected

try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
let toggle = try view.find(ViewType.Toggle.self)
#expect(toggle.isDisabled())
#expect(throws: Never.self) { try toggle.labelView().find(text: "HTTP Headers") }
}
}
}

@Test
func testToggleEnabledWhenVPNDisabled() async throws {
vpn.state = .disabled

try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
let toggle = try view.find(ViewType.Toggle.self)
#expect(!toggle.isDisabled())
#expect(throws: Never.self) { try toggle.labelView().find(text: "HTTP Headers") }
}
}
}

// TODO: More tests, ViewInspector cannot currently inspect Tables
}
9 changes: 5 additions & 4 deletions Coder Desktop/Coder DesktopTests/LoginFormTests.swift
Original file line number Diff line number Diff line change
@@ -15,7 +15,9 @@ struct LoginTests {
init() {
session = MockSession()
sut = LoginForm<MockSession>()
view = sut.environmentObject(session)
let store = UserDefaults(suiteName: #file)!
store.removePersistentDomain(forName: #file)
view = sut.environmentObject(session).environmentObject(Settings(store: store))
}

@Test
@@ -70,12 +72,11 @@ struct LoginTests {

@Test
func testFailedAuthentication() async throws {
let login = LoginForm<MockSession>()
let url = URL(string: "https://testFailedAuthentication.com")!
Mock(url: url.appendingPathComponent("/api/v2/users/me"), statusCode: 401, data: [.get: Data()]).register()

try await ViewHosting.host(login.environmentObject(session)) {
try await login.inspection.inspect { view in
try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
try view.find(ViewType.TextField.self).setInput(url.absoluteString)
try view.find(button: "Next").tap()
#expect(throws: Never.self) { try view.find(text: "Session Token") }
3 changes: 1 addition & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -21,8 +21,7 @@ test:
-testPlan $(SCHEME) \
-skipPackagePluginValidation \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \
| LC_ALL="en_US.UTF-8" xcpretty
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was debugging a CI-only test failure and xcpretty was swallowing a helpful error log - xcbeautify seems like it provides better output in general for tests.

For context, the default xcodebuild output is thousands of lines, and not at all readable.

CODE_SIGNING_ALLOWED=NO | xcbeautify

lint:
swiftlint \