Skip to content
Open
Show file tree
Hide file tree
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
138 changes: 136 additions & 2 deletions Remux.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions RemuxApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
<string>Remux uses the microphone so you can dictate messages.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Remux uses on-device speech recognition to turn your dictation into text.</string>
<key>RemuxApplicationKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)dev.remux.app</string>
<key>RemuxSharedKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)dev.remux.shared</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
Expand Down
15 changes: 15 additions & 0 deletions RemuxApp/Remux.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.dev.remux</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)dev.remux.app</string>
<string>$(AppIdentifierPrefix)dev.remux.shared</string>
</array>
</dict>
</plist>
23 changes: 23 additions & 0 deletions RemuxApp/Sources/App/RemuxAppDependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,29 @@ struct RemuxAppDependencies: Sendable {
}
}

static func fileProviderCredentialStores(
infoDictionary: [String: Any] = Bundle.main.infoDictionary ?? [:],
service: String = KeychainSSHCredentialStore.defaultService
) throws -> (
application: KeychainSSHCredentialStore,
shared: KeychainSSHCredentialStore
) {
(
application: KeychainSSHCredentialStore(
service: service,
accessGroup: try FileProviderSharedConfiguration.applicationKeychainAccessGroup(
infoDictionary: infoDictionary
)
),
shared: KeychainSSHCredentialStore(
service: service,
accessGroup: try FileProviderSharedConfiguration.keychainAccessGroup(
infoDictionary: infoDictionary
)
)
)
}

func makeTransport(for target: TmuxConnectionTarget) -> any TmuxControlTransport {
transportFactory(target, trustedHostStore, sshRootService)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation
import NIOCore

struct FileProviderCitadelSFTPClientProvider: FileProviderSFTPClientProviding {
private let sshRootService: RemuxSSHRootService
private let trustedHosts: TrustedHostStore
private let connectTimeout: TimeAmount
private let operationTimeout: TimeAmount

init(
sshRootService: RemuxSSHRootService,
trustedHosts: TrustedHostStore,
connectTimeout: TimeAmount = .seconds(15),
operationTimeout: TimeAmount = .seconds(15)
) {
self.sshRootService = sshRootService
self.trustedHosts = trustedHosts
self.connectTimeout = connectTimeout
self.operationTimeout = operationTimeout
}

func withClient<Value: Sendable>(
server: SavedServer,
authentication: ResolvedSSHAuth,
operation: @Sendable (any RemuxSFTPFileProviderClient) async throws -> Value
) async throws -> Value {
let credential: SSHCredential
switch authentication.credential {
case .password(let password):
credential = .password(password)
case .privateKey(let privateKey):
credential = .privateKey(privateKey)
}

let provider = RemuxCitadelSFTPClientProvider(
sshRootService: sshRootService,
rootKey: RemuxSSHRootKey(server: server, auth: authentication),
rootConfiguration: RemuxSSHRootConfiguration(
host: server.host,
port: server.port,
authenticationMethod: {
try SSHAuthenticationMethodFactory.make(
username: authentication.username,
credential: credential
)
},
hostKeyValidator: trustedHosts.validator(for: server),
connectTimeout: connectTimeout
),
operationTimeout: operationTimeout
)

return try await provider.withClient { client in
try await operation(client)
}
}

func closeIdleConnections(forServerID serverID: SavedServer.ID) async {
await sshRootService.closeIdleConnections(forServerID: serverID)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import Foundation

actor FileProviderDomainOperationCoordinator {
private final class Request: Hashable, Sendable {
static func == (lhs: Request, rhs: Request) -> Bool {
lhs === rhs
}

func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(self))
}
}

private struct Waiter {
let request: Request
let continuation: CheckedContinuation<Void, Error>
}

private var activeRequest: Request?
private var waiters: [Waiter] = []
private var registeredRequests: Set<Request> = []
private var cancelledBeforeEnqueue: Set<Request> = []

func perform<Value: Sendable>(
_ operation: @escaping @Sendable () async throws -> Value
) async throws -> Value {
let request = Request()
registeredRequests.insert(request)
return try await withTaskCancellationHandler {
defer { complete(request) }
try await acquire(request)
try Task.checkCancellation()
return try await operation()
} onCancel: {
Task { await self.cancel(request) }
}
}

private func acquire(_ request: Request) async throws {
guard registeredRequests.remove(request) != nil else {
throw CancellationError()
}
guard cancelledBeforeEnqueue.remove(request) == nil else {
throw CancellationError()
}
guard activeRequest != nil else {
activeRequest = request
return
}
try await withCheckedThrowingContinuation { continuation in
waiters.append(Waiter(request: request, continuation: continuation))
}
}

private func cancel(_ request: Request) {
guard activeRequest !== request else { return }
guard let index = waiters.firstIndex(where: { $0.request === request }) else {
guard registeredRequests.contains(request) else { return }
cancelledBeforeEnqueue.insert(request)
return
}
waiters.remove(at: index).continuation.resume(
throwing: CancellationError()
)
}

private func complete(_ request: Request) {
registeredRequests.remove(request)
cancelledBeforeEnqueue.remove(request)
release(request)
}

private func release(_ request: Request) {
guard activeRequest === request else { return }
guard !waiters.isEmpty else {
activeRequest = nil
return
}
let next = waiters.removeFirst()
activeRequest = next.request
next.continuation.resume()
}
}
148 changes: 148 additions & 0 deletions RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import FileProvider
import Foundation

struct FileProviderDomainRecord: Equatable, Sendable {
let serverID: SavedServer.ID
let displayName: String

fileprivate var rawIdentifier: String {
serverID.uuidString.lowercased()
}
}

protocol FileProviderDomainRegistering: Sendable {
func records() async throws -> [FileProviderDomainRecord]
func add(_ record: FileProviderDomainRecord) async throws
func remove(serverID: SavedServer.ID) async throws
}

protocol FileProviderDomainReconciling: Sendable {
func reconcile() async throws
}

actor FileProviderDomainReconciler: FileProviderDomainReconciling {
private let profiles: any ConnectionProfileRepository
private let credentials: any SSHCredentialStore
private let trust: TrustedHostStore
private let registry: any FileProviderDomainRegistering
private var reconciliationTask: Task<Void, Error>?

init(
profiles: any ConnectionProfileRepository,
credentials: any SSHCredentialStore,
trust: TrustedHostStore,
registry: any FileProviderDomainRegistering
) {
self.profiles = profiles
self.credentials = credentials
self.trust = trust
self.registry = registry
}

func reconcile() async throws {
if let reconciliationTask {
try await reconciliationTask.value
return
}

let task = Task {
try await self.reconcileDomains()
}
reconciliationTask = task

do {
try await task.value
reconciliationTask = nil
} catch {
reconciliationTask = nil
throw error
}
}

private func reconcileDomains() async throws {
let snapshot = try await profiles.loadSnapshot()
let trustedIdentities = try trust.loadIdentities()
var desiredRecords: [FileProviderDomainRecord] = []

for server in snapshot.servers where trustedIdentities.contains(where: {
$0.serverID == server.id && $0.host == server.host
}) {
guard try await credentials.loadCredential(identityID: server.identityID) != nil else {
continue
}

desiredRecords.append(
FileProviderDomainRecord(serverID: server.id, displayName: server.displayName)
)
}

let existingRecords = try await registry.records()

for record in existingRecords
where desiredRecords.first(where: { $0.serverID == record.serverID }) != record {
try await registry.remove(serverID: record.serverID)
}

for record in desiredRecords
where existingRecords.first(where: { $0.serverID == record.serverID }) != record {
try await registry.add(record)
}
}
}

final class NSFileProviderDomainRegistry: FileProviderDomainRegistering, @unchecked Sendable {
func records() async throws -> [FileProviderDomainRecord] {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[FileProviderDomainRecord], Error>) in
NSFileProviderManager.getDomainsWithCompletionHandler { domains, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: domains.compactMap { domain in
guard let serverID = UUID(uuidString: domain.identifier.rawValue) else {
return nil
}

return FileProviderDomainRecord(
serverID: serverID,
displayName: domain.displayName
)
})
}
}
}
}

func add(_ record: FileProviderDomainRecord) async throws {
let domain = NSFileProviderDomain(
identifier: NSFileProviderDomainIdentifier(rawValue: record.rawIdentifier),
displayName: record.displayName
)

try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
NSFileProviderManager.add(domain) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
}
}
}

func remove(serverID: SavedServer.ID) async throws {
let domain = NSFileProviderDomain(
identifier: NSFileProviderDomainIdentifier(rawValue: serverID.uuidString.lowercased()),
displayName: ""
)

try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
NSFileProviderManager.remove(domain) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
}
}
}
}
Loading
Loading