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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ node_modules/
dist/
*.log

# Generated caches from local scripts and Android builds.
__pycache__/
.gradle/
.kotlin/

# xcodebuild output
Build/
*.xcworkspace/xcuserdata/
Expand Down
2 changes: 1 addition & 1 deletion ExampleApp/ConnectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ struct ConnectionView: View {

Section {
Button("Stop listening") { NectoSDK.stop() }
Button("Start listening") { NectoSDK.start() }
Button("Start listening") { ExampleApp.startNecto() }
} footer: {
Text("Necto reaches this app over USB on a device, and over loopback in a simulator.")
}
Expand Down
10 changes: 9 additions & 1 deletion ExampleApp/ExampleApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ struct ExampleApp: App {
Self.seedPreferences()
Self.seedFiles()

NectoSDK.start()
Self.startNecto()
ExampleTelemetry.start()
}

Expand All @@ -47,6 +47,14 @@ struct ExampleApp: App {
}
}

static func startNecto() {
if let publicKey = ProcessInfo.processInfo.environment["NECTO_PUBLIC_KEY"] {
NectoSDK.start(publicKey: publicKey)
} else {
NectoSDK.start()
}
}

/// A few files of each shape a real app keeps, written once.
private static func seedFiles() {
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
Expand Down
10 changes: 9 additions & 1 deletion Necto.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
AE00000000000000000009 /* NectoMacService in Frameworks */ = {isa = PBXBuildFile; productRef = 99A1DE268E90C14804203FA5 /* NectoMacService */; };
AF1000000000000000000018 /* NectoAgentSkills.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1000000000000000000019 /* NectoAgentSkills.swift */; };
AF1000000000000000000016 /* NectoCLIInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1000000000000000000017 /* NectoCLIInstaller.swift */; };
AF1000000000000000000000 /* NectoDevicePluginStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1000000000000000000001 /* NectoDevicePluginStore.swift */; };
Expand Down Expand Up @@ -52,6 +53,12 @@
/* End PBXFileSystemSynchronizedRootGroup section */

/* Begin PBXFrameworksBuildPhase section */
AE00000000000000000010 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (AE00000000000000000009 /* NectoMacService in Frameworks */,);
runOnlyForDeploymentPostprocessing = 0;
};
AF00000000000000000006 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
Expand Down Expand Up @@ -123,11 +130,12 @@
AE00000000000000000003 /* NectoE2ETests */ = {
isa = PBXNativeTarget;
buildConfigurationList = AE00000000000000000004 /* Build configuration list for PBXNativeTarget "NectoE2ETests" */;
buildPhases = (AE00000000000000000005 /* Sources */,);
buildPhases = (AE00000000000000000005 /* Sources */, AE00000000000000000010 /* Frameworks */,);
buildRules = ();
dependencies = ();
fileSystemSynchronizedGroups = (AE00000000000000000002 /* NectoE2ETests */,);
name = NectoE2ETests;
packageProductDependencies = (99A1DE268E90C14804203FA5 /* NectoMacService */,);
productName = NectoE2ETests;
productReference = AE00000000000000000001 /* NectoE2ETests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
Expand Down

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

4 changes: 4 additions & 0 deletions Necto/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ struct ContentView: View {
}
.plainRow()
}
ForEach(app.unauthorized) { device in
UnauthorizedDeviceRow(device: device, scale: scale)
.plainRow()
}
}

if !window.activeDevicePlugins.isEmpty || !window.disabledDevicePlugins.isEmpty {
Expand Down
22 changes: 17 additions & 5 deletions Necto/NectoAppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ final class NectoAppModel {
var queuedAssets: Set<String> { installer.queuedAssets }

private(set) var connectedApps: [NectoConnectedApp] = []
private(set) var unauthorizedApps: [NectoUnauthorizedApp] = []

let backgroundPanels = NectoBackgroundPanels()
let cliInstaller = NectoCLIInstaller()
Expand Down Expand Up @@ -151,6 +152,7 @@ final class NectoAppModel {
private(set) var unreadIssueCount = 0
private let appBridge: NectoDeviceBridgeClient
private var connectionTask: Task<Void, Never>?
private var authorizationTask: Task<Void, Never>?

init() {
shellAccess = NectoShellAccessController(snapshot: NectoPluginLibrary.shellPolicy) {
Expand Down Expand Up @@ -405,6 +407,7 @@ final class NectoAppModel {
let name: String
/// The same app on each device it was found on, newest listing first.
let devices: [NectoConnectedApp]
let unauthorized: [NectoUnauthorizedApp]

var id: String { bundleID }
}
Expand All @@ -424,9 +427,12 @@ final class NectoAppModel {
grouped[key, default: []].append(app)
}

return order.compactMap { key in
guard let devices = grouped[key], let first = devices.first else { return nil }
return ConnectedApp(bundleID: key, name: first.appName, devices: devices)
let denied = Dictionary(grouping: unauthorizedApps, by: \.appBundleID)
for app in unauthorizedApps where !order.contains(app.appBundleID) { order.append(app.appBundleID) }
return order.map { key in
let devices = grouped[key] ?? []
let unauthorized = denied[key] ?? []
return ConnectedApp(bundleID: key, name: devices.first?.appName ?? unauthorized.first?.appName ?? key, devices: devices, unauthorized: unauthorized)
}
}

Expand All @@ -436,8 +442,11 @@ final class NectoAppModel {
private func startControlServer() async {
let server = NectoControlServer(handler: NectoControlBridge(
registry: registry,
connectedApps: { [weak self] in
await MainActor.run { self?.connectedApps ?? [] }
connectedApps: { [connections] in
await connections.connectedApps
},
unauthorizedApps: { [connections] in
await connections.unauthorizedApps
},
install: { [weak self] source in
guard let self else {
Expand Down Expand Up @@ -478,6 +487,9 @@ final class NectoAppModel {

private func startWatchingConnections() {
guard connectionTask == nil else { return }
authorizationTask = Task { [connections] in
for await apps in await connections.authorizationUpdates() { unauthorizedApps = apps }
}

// The transport reports facts; which of them is worth telling someone about is
// decided here, where what the person was trying to do is known.
Expand Down
63 changes: 63 additions & 0 deletions Necto/NectoSidebar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,69 @@ struct AppHeader: View {
}
}

struct UnauthorizedDeviceRow: View {
let device: NectoUnauthorizedApp
let scale: CGFloat

@State private var isShowingGuidance = false
@State private var isHovering = false

var body: some View {
Button { isShowingGuidance = true } label: {
HStack(spacing: 8) {
Image(systemName: device.connection == .simulator ? "laptopcomputer.and.iphone" : "iphone")
.foregroundStyle(NectoTheme.textTertiary)
.frame(width: 16)
Text(device.deviceName)
.foregroundStyle(NectoTheme.textSecondary)
.lineLimit(1)
.layoutPriority(1)
Spacer(minLength: 6)
Text("Unauthorized")
.font(.necto(.caption, scale: scale))
.foregroundStyle(NectoTheme.textTertiary)
.lineLimit(1)
Circle().fill(NectoTheme.danger).frame(width: 6, height: 6)
}
.font(.necto(.label, scale: scale))
.padding(.horizontal, 8)
.frame(height: NectoTheme.rowHeight * scale)
.background(isHovering ? NectoTheme.hover : .clear, in: RoundedRectangle(cornerRadius: NectoTheme.radiusControl))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.horizontal, 8)
.onHover { isHovering = $0 }
.help(tooltip)
.accessibilityLabel(device.deviceName + ", " + NectoL10n.text("Unauthorized"))
.accessibilityIdentifier("unauthorized-" + device.id)
.alert("Authentication required", isPresented: $isShowingGuidance) {
Button("Done", role: .cancel) {}
} message: {
Text([
device.appName + "\n" + device.appBundleID,
failureReason,
NectoL10n.text("Debugging this app is blocked in both Necto and the CLI."),
NectoL10n.text("Ask the app's maintainer how to set up its connection key on this Mac. Necto reconnects automatically once the correct key is available."),
].joined(separator: "\n\n"))
}
}

private var failureReason: String {
let reason: String
switch device.reason {
case .missingKey: reason = "Install this app's connection key on your Mac to start debugging."
case .rejectedKey: reason = "Authentication failed. Check that the Mac key matches the app's public key."
case .credentialUnavailable: reason = "The connection key could not be read. Check your Mac Keychain access."
}
return NectoL10n.text(reason)
}

private var tooltip: String {
[NectoL10n.text("Unauthorized"), device.appBundleID, failureReason].joined(separator: "\n")
}
}

/// One device the app is running on. Selecting it is what points a plugin somewhere.
struct DeviceRow: View {
let device: NectoConnectedApp
Expand Down
7 changes: 7 additions & 0 deletions Necto/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,10 @@

"Search plugins" = "Search plugins";
"No plugins found" = "No plugins found";
"Unauthorized" = "Unauthorized";
"Install this app's connection key on your Mac to start debugging." = "Install this app's connection key on your Mac to start debugging.";
"Authentication failed. Check that the Mac key matches the app's public key." = "Authentication failed. Check that the Mac key matches the app's public key.";
"The connection key could not be read. Check your Mac Keychain access." = "The connection key could not be read. Check your Mac Keychain access.";
"Authentication required" = "Authentication required";
"Debugging this app is blocked in both Necto and the CLI." = "Debugging this app is blocked in both Necto and the CLI.";
"Ask the app's maintainer how to set up its connection key on this Mac. Necto reconnects automatically once the correct key is available." = "Ask the app's maintainer how to set up its connection key on this Mac. Necto reconnects automatically once the correct key is available.";
7 changes: 7 additions & 0 deletions Necto/ko.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,10 @@

"Search plugins" = "플러그인 검색";
"No plugins found" = "검색 결과가 없어요";
"Unauthorized" = "인증되지 않음";
"Install this app's connection key on your Mac to start debugging." = "디버깅을 시작하려면 Mac에 이 앱의 연결 키를 설치하세요.";
"Authentication failed. Check that the Mac key matches the app's public key." = "인증에 실패했어요. Mac의 키가 앱의 공개키와 일치하는지 확인하세요.";
"The connection key could not be read. Check your Mac Keychain access." = "연결 키를 읽지 못했어요. Mac 키체인 접근 권한을 확인하세요.";
"Authentication required" = "연결 인증이 필요해요";
"Debugging this app is blocked in both Necto and the CLI." = "Necto와 CLI에서 이 앱을 디버깅할 수 없어요.";
"Ask the app's maintainer how to set up its connection key on this Mac. Necto reconnects automatically once the correct key is available." = "설정 방법은 앱 담당자에게 문의하세요. 이 Mac에 올바른 연결 키를 설정하면 자동으로 다시 연결돼요.";
Loading
Loading