diff --git a/scripts/cfw_install.sh b/scripts/cfw_install.sh index dc1de28..f35b867 100755 --- a/scripts/cfw_install.sh +++ b/scripts/cfw_install.sh @@ -443,6 +443,7 @@ VPHONED_SRCS=( "$VPHONED_SRC/vphoned_devmode.m" "$VPHONED_SRC/vphoned_location.m" "$VPHONED_SRC/vphoned_files.m" + "$VPHONED_SRC/vphoned_keychain.m" ) needs_vphoned_build=0 if [[ ! -f "$VPHONED_BIN" ]]; then @@ -462,6 +463,7 @@ if [[ "$needs_vphoned_build" == "1" ]]; then -I"$VPHONED_SRC/vendor/libarchive" \ -o "$VPHONED_BIN" "${VPHONED_SRCS[@]}" \ -larchive \ + -lsqlite3 \ -framework Foundation \ -framework Security \ -framework CoreServices diff --git a/scripts/cfw_install_dev.sh b/scripts/cfw_install_dev.sh index 885ade5..d44f63d 100755 --- a/scripts/cfw_install_dev.sh +++ b/scripts/cfw_install_dev.sh @@ -445,6 +445,7 @@ VPHONED_SRCS=( "$VPHONED_SRC/vphoned_devmode.m" "$VPHONED_SRC/vphoned_location.m" "$VPHONED_SRC/vphoned_files.m" + "$VPHONED_SRC/vphoned_keychain.m" ) needs_vphoned_build=0 if [[ ! -f "$VPHONED_BIN" ]]; then @@ -464,6 +465,7 @@ if [[ "$needs_vphoned_build" == "1" ]]; then -I"$VPHONED_SRC/vendor/libarchive" \ -o "$VPHONED_BIN" "${VPHONED_SRCS[@]}" \ -larchive \ + -lsqlite3 \ -framework Foundation \ -framework Security \ -framework CoreServices diff --git a/scripts/vphoned/Makefile b/scripts/vphoned/Makefile index 8e3bb0c..4d15540 100644 --- a/scripts/vphoned/Makefile +++ b/scripts/vphoned/Makefile @@ -18,6 +18,7 @@ $(OUT): $(SRCS) $(wildcard *.h) -DVPHONED_BUILD_HASH='"$(GIT_HASH)"' \ -o $@ $(SRCS) \ -larchive \ + -lsqlite3 \ -framework Foundation \ -framework Security \ -framework CoreServices diff --git a/scripts/vphoned/vphoned.m b/scripts/vphoned/vphoned.m index b1201a9..0805b65 100644 --- a/scripts/vphoned/vphoned.m +++ b/scripts/vphoned/vphoned.m @@ -26,6 +26,7 @@ #import "vphoned_location.h" #import "vphoned_files.h" #import "vphoned_install.h" +#import "vphoned_keychain.h" #ifndef AF_VSOCK #define AF_VSOCK 40 @@ -254,7 +255,7 @@ static BOOL handle_client(int fd) { } // Build capabilities list - NSMutableArray *caps = [NSMutableArray arrayWithObjects:@"hid", @"devmode", @"file", nil]; + NSMutableArray *caps = [NSMutableArray arrayWithObjects:@"hid", @"devmode", @"file", @"keychain", nil]; if (vp_location_available()) [caps addObject:@"location"]; if (vp_custom_installer_available()) [caps addObject:@"ipa_install"]; @@ -301,6 +302,13 @@ static BOOL handle_client(int fd) { continue; } + // Keychain operations + if ([t hasPrefix:@"keychain_"]) { + NSDictionary *resp = vp_handle_keychain_command(msg); + if (resp && !vp_write_message(fd, resp)) break; + continue; + } + NSDictionary *resp = handle_command(msg); if (resp && !vp_write_message(fd, resp)) break; } diff --git a/scripts/vphoned/vphoned_keychain.h b/scripts/vphoned/vphoned_keychain.h new file mode 100644 index 0000000..35c8da0 --- /dev/null +++ b/scripts/vphoned/vphoned_keychain.h @@ -0,0 +1,12 @@ +/* + * vphoned_keychain — Remote keychain enumeration over vsock. + * + * Handles keychain_list: queries SecItemCopyMatching for all keychain + * classes and returns attributes as JSON. + */ + +#pragma once +#import + +/// Handle a keychain command. Returns a response dict. +NSDictionary *vp_handle_keychain_command(NSDictionary *msg); diff --git a/scripts/vphoned/vphoned_keychain.m b/scripts/vphoned/vphoned_keychain.m new file mode 100644 index 0000000..38f0537 --- /dev/null +++ b/scripts/vphoned/vphoned_keychain.m @@ -0,0 +1,254 @@ +/* + * vphoned_keychain — Remote keychain enumeration over vsock. + * + * Uses both SecItemCopyMatching (for items we're entitled to) and direct + * sqlite3 access to /var/Keychains/keychain-2.db (for everything else). + * The sqlite approach bypasses access-group entitlement checks entirely. + */ + +#import "vphoned_keychain.h" +#import "vphoned_protocol.h" +#import +#import + +// MARK: - Helpers + +/// Convert a CFType keychain attribute value to a JSON-safe NSObject. +static id safe_value(id val) { + if (!val || val == (id)kCFNull) return [NSNull null]; + if ([val isKindOfClass:[NSString class]]) return val; + if ([val isKindOfClass:[NSNumber class]]) return val; + if ([val isKindOfClass:[NSDate class]]) { + return @([(NSDate *)val timeIntervalSince1970]); + } + if ([val isKindOfClass:[NSData class]]) { + NSString *str = [[NSString alloc] initWithData:val encoding:NSUTF8StringEncoding]; + if (str) return str; + return [(NSData *)val base64EncodedStringWithOptions:0]; + } + return [val description]; +} + +// MARK: - SQLite-based keychain reader + +static NSString *KEYCHAIN_DB_PATH = @"/var/Keychains/keychain-2.db"; + +/// Map sqlite table name to our class abbreviation. +static NSDictionary *tableToClass(void) { + return @{ + @"genp": @"genp", + @"inet": @"inet", + @"cert": @"cert", + @"keys": @"keys", + }; +} + +/// Read a text column, returning @"" if NULL. +static NSString *col_text(sqlite3_stmt *stmt, int col) { + const unsigned char *val = sqlite3_column_text(stmt, col); + if (!val) return @""; + return [NSString stringWithUTF8String:(const char *)val]; +} + +/// Read a blob column as base64 string. +static NSString *col_blob_base64(sqlite3_stmt *stmt, int col) { + const void *blob = sqlite3_column_blob(stmt, col); + int size = sqlite3_column_bytes(stmt, col); + if (!blob || size <= 0) return @""; + NSData *data = [NSData dataWithBytes:blob length:size]; + // Try UTF-8 first + NSString *str = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + if (str) return str; + return [data base64EncodedStringWithOptions:0]; +} + +/// Query one table from the keychain DB via sqlite3. +static NSArray *query_db_table(sqlite3 *db, NSString *table, NSString *className, NSMutableArray *diag) { + // Columns available in genp/inet tables: + // rowid, acct, svce, agrp, labl, data, cdat, mdat, desc, icmt, type, crtr, pdmn + // inet also has: srvr, ptcl, port, path + NSString *sql; + BOOL isInet = [table isEqualToString:@"inet"]; + BOOL isCert = [table isEqualToString:@"cert"]; + BOOL isKeys = [table isEqualToString:@"keys"]; + + if (isInet) { + sql = [NSString stringWithFormat: + @"SELECT rowid, acct, svce, agrp, labl, data, cdat, mdat, pdmn, srvr, ptcl, port, path FROM %@", table]; + } else if (isCert || isKeys) { + sql = [NSString stringWithFormat: + @"SELECT rowid, agrp, labl, data, cdat, mdat, pdmn FROM %@", table]; + } else { + sql = [NSString stringWithFormat: + @"SELECT rowid, acct, svce, agrp, labl, data, cdat, mdat, pdmn FROM %@", table]; + } + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, sql.UTF8String, -1, &stmt, NULL); + if (rc != SQLITE_OK) { + [diag addObject:[NSString stringWithFormat:@"%@: sqlite error %d", className, rc]]; + return @[]; + } + + NSMutableArray *output = [NSMutableArray array]; + while (sqlite3_step(stmt) == SQLITE_ROW) { + NSMutableDictionary *entry = [NSMutableDictionary dictionary]; + entry[@"class"] = className; + + int col = 0; + int rowid = sqlite3_column_int(stmt, col++); + + if (!isCert && !isKeys) { + entry[@"account"] = col_text(stmt, col++); + entry[@"service"] = col_text(stmt, col++); + } + entry[@"accessGroup"] = col_text(stmt, col++); + entry[@"label"] = col_text(stmt, col++); + + // Value data + const void *blob = sqlite3_column_blob(stmt, col); + int blobSize = sqlite3_column_bytes(stmt, col); + if (blob && blobSize > 0) { + NSData *data = [NSData dataWithBytes:blob length:blobSize]; + NSString *str = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + if (str) { + entry[@"value"] = str; + entry[@"valueEncoding"] = @"utf8"; + } else { + entry[@"value"] = [data base64EncodedStringWithOptions:0]; + entry[@"valueEncoding"] = @"base64"; + } + entry[@"valueSize"] = @(blobSize); + } + col++; + + // Dates (stored as text in sqlite, e.g. "2025-01-15 12:34:56") + NSString *cdat = col_text(stmt, col++); + NSString *mdat = col_text(stmt, col++); + if (cdat.length > 0) entry[@"createdStr"] = cdat; + if (mdat.length > 0) entry[@"modifiedStr"] = mdat; + + // Protection class (pdmn) + NSString *pdmn = col_text(stmt, col++); + if (pdmn.length > 0) entry[@"protection"] = pdmn; + + // inet-specific fields + if (isInet) { + NSString *server = col_text(stmt, col++); + if (server.length > 0) entry[@"server"] = server; + NSString *protocol = col_text(stmt, col++); + if (protocol.length > 0) entry[@"protocol"] = protocol; + int port = sqlite3_column_int(stmt, col++); + if (port > 0) entry[@"port"] = @(port); + NSString *path = col_text(stmt, col++); + if (path.length > 0) entry[@"path"] = path; + } + + // Use rowid for unique ID generation + entry[@"_rowid"] = @(rowid); + [output addObject:entry]; + } + + sqlite3_finalize(stmt); + + NSUInteger count = output.count; + if (count > 0) { + [diag addObject:[NSString stringWithFormat:@"%@: %lu rows", className, (unsigned long)count]]; + } else { + [diag addObject:[NSString stringWithFormat:@"%@: empty", className]]; + } + return output; +} + +/// Read all keychain items directly from the sqlite database. +static NSDictionary *query_keychain_db(NSString *filterClass, NSMutableArray *diag) { + sqlite3 *db = NULL; + int rc = sqlite3_open_v2(KEYCHAIN_DB_PATH.UTF8String, &db, SQLITE_OPEN_READONLY, NULL); + if (rc != SQLITE_OK) { + [diag addObject:[NSString stringWithFormat:@"db open failed: %d", rc]]; + NSLog(@"vphoned: sqlite3_open(%@) failed: %d", KEYCHAIN_DB_PATH, rc); + return @{@"items": @[], @"diag": diag}; + } + + [diag addObject:[NSString stringWithFormat:@"opened %@", KEYCHAIN_DB_PATH]]; + + NSMutableArray *allItems = [NSMutableArray array]; + + struct { NSString *table; NSString *name; } tables[] = { + { @"genp", @"genp" }, + { @"inet", @"inet" }, + { @"cert", @"cert" }, + { @"keys", @"keys" }, + }; + + for (size_t i = 0; i < sizeof(tables) / sizeof(tables[0]); i++) { + if (filterClass && ![filterClass isEqualToString:tables[i].name]) continue; + NSArray *items = query_db_table(db, tables[i].table, tables[i].name, diag); + [allItems addObjectsFromArray:items]; + } + + sqlite3_close(db); + return @{@"items": allItems}; +} + +// MARK: - Command Handler + +NSDictionary *vp_handle_keychain_command(NSDictionary *msg) { + id reqId = msg[@"id"]; + NSString *type = msg[@"t"]; + + // Add a test keychain item (for debugging) + if ([type isEqualToString:@"keychain_add"]) { + NSString *account = msg[@"account"] ?: @"vphone-test"; + NSString *service = msg[@"service"] ?: @"vphone"; + NSString *password = msg[@"password"] ?: @"testpass123"; + + NSDictionary *deleteQuery = @{ + (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword, + (__bridge id)kSecAttrAccount: account, + (__bridge id)kSecAttrService: service, + }; + SecItemDelete((__bridge CFDictionaryRef)deleteQuery); + + NSDictionary *attrs = @{ + (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword, + (__bridge id)kSecAttrAccount: account, + (__bridge id)kSecAttrService: service, + (__bridge id)kSecAttrLabel: [NSString stringWithFormat:@"%@ (%@)", service, account], + (__bridge id)kSecValueData: [password dataUsingEncoding:NSUTF8StringEncoding], + }; + + OSStatus status = SecItemAdd((__bridge CFDictionaryRef)attrs, NULL); + NSLog(@"vphoned: keychain_add: account=%@ service=%@ status=%d", account, service, (int)status); + + NSMutableDictionary *resp = vp_make_response(@"keychain_add", reqId); + resp[@"status"] = @(status); + resp[@"ok"] = @(status == errSecSuccess); + if (status != errSecSuccess) { + resp[@"msg"] = [NSString stringWithFormat:@"SecItemAdd failed: %d", (int)status]; + } + return resp; + } + + if ([type isEqualToString:@"keychain_list"]) { + NSString *filterClass = msg[@"class"]; + NSMutableArray *diag = [NSMutableArray array]; + + // Primary: read directly from sqlite DB (bypasses entitlement checks) + NSDictionary *dbResult = query_keychain_db(filterClass, diag); + NSArray *dbItems = dbResult[@"items"]; + + NSLog(@"vphoned: keychain_list: %lu items (sqlite), diag: %@", + (unsigned long)dbItems.count, diag); + + NSMutableDictionary *resp = vp_make_response(@"keychain_list", reqId); + resp[@"items"] = dbItems; + resp[@"count"] = @(dbItems.count); + resp[@"diag"] = diag; + return resp; + } + + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"unknown keychain command: %@", type]; + return r; +} diff --git a/sources/vphone-cli/VPhoneAppDelegate.swift b/sources/vphone-cli/VPhoneAppDelegate.swift index f488176..ffc83ce 100644 --- a/sources/vphone-cli/VPhoneAppDelegate.swift +++ b/sources/vphone-cli/VPhoneAppDelegate.swift @@ -9,6 +9,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { private var windowController: VPhoneWindowController? private var menuController: VPhoneMenuController? private var fileWindowController: VPhoneFileWindowController? + private var keychainWindowController: VPhoneKeychainWindowController? private var locationProvider: VPhoneLocationProvider? private var sigintSource: DispatchSourceSignal? @@ -126,6 +127,9 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { let fileWC = VPhoneFileWindowController() fileWindowController = fileWC + let keychainWC = VPhoneKeychainWindowController() + keychainWindowController = keychainWC + let mc = VPhoneMenuController(keyHelper: keyHelper, control: control) mc.vm = vm mc.captureView = wc.captureView @@ -133,6 +137,10 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { guard let fileWC, let control else { return } fileWC.showWindow(control: control) } + mc.onKeychainPressed = { [weak keychainWC, weak control] in + guard let keychainWC, let control else { return } + keychainWC.showWindow(control: control) + } if let provider = locationProvider { mc.locationProvider = provider } diff --git a/sources/vphone-cli/VPhoneControl.swift b/sources/vphone-cli/VPhoneControl.swift index 30ed421..9c40923 100644 --- a/sources/vphone-cli/VPhoneControl.swift +++ b/sources/vphone-cli/VPhoneControl.swift @@ -467,6 +467,35 @@ class VPhoneControl { return "Installed \(localURL.lastPathComponent) through the built-in IPA installer." } + // MARK: - Keychain Operations + + struct KeychainResult { + let items: [[String: Any]] + let diagnostics: [String] + } + + func listKeychainItems(filterClass: String? = nil) async throws -> KeychainResult { + var req: [String: Any] = ["t": "keychain_list"] + if let filterClass { req["class"] = filterClass } + let (resp, _) = try await sendRequest(req) + guard let items = resp["items"] as? [[String: Any]] else { + throw ControlError.protocolError("missing items in keychain response") + } + let diag = resp["diag"] as? [String] ?? [] + return KeychainResult(items: items, diagnostics: diag) + } + + func addKeychainItem(account: String = "vphone-test", service: String = "vphone", password: String = "testpass123") async throws -> Bool { + let req: [String: Any] = ["t": "keychain_add", "account": account, "service": service, "password": password] + let (resp, _) = try await sendRequest(req) + let ok = resp["ok"] as? Bool ?? false + if !ok { + let msg = resp["msg"] as? String ?? "unknown error" + throw ControlError.protocolError("keychain_add: \(msg)") + } + return true + } + // MARK: - Location func sendLocation( @@ -656,7 +685,7 @@ class VPhoneControl { switch type { case "file_get", "file_put", "ipa_install": transferRequestTimeout - case "devmode", "file_list", "file_delete", "file_rename", "file_mkdir": + case "devmode", "file_list", "file_delete", "file_rename", "file_mkdir", "keychain_list": slowRequestTimeout default: defaultRequestTimeout diff --git a/sources/vphone-cli/VPhoneKeychainBrowserModel.swift b/sources/vphone-cli/VPhoneKeychainBrowserModel.swift new file mode 100644 index 0000000..29b572a --- /dev/null +++ b/sources/vphone-cli/VPhoneKeychainBrowserModel.swift @@ -0,0 +1,102 @@ +import Foundation +import Observation + +@Observable +@MainActor +class VPhoneKeychainBrowserModel { + let control: VPhoneControl + + var items: [VPhoneKeychainItem] = [] + var isLoading = false + var error: String? + var diagnostics: [String] = [] + var searchText = "" + var selection = Set() + var sortOrder = [KeyPathComparator(\VPhoneKeychainItem.displayName)] + var filterClass: String? + var showDiagnostics = false + + init(control: VPhoneControl) { + self.control = control + } + + // MARK: - Computed + + var filteredItems: [VPhoneKeychainItem] { + var list = items + if let filterClass { + list = list.filter { $0.itemClass == filterClass } + } + if !searchText.isEmpty { + let query = searchText.lowercased() + list = list.filter { + $0.account.lowercased().contains(query) + || $0.service.lowercased().contains(query) + || $0.label.lowercased().contains(query) + || $0.accessGroup.lowercased().contains(query) + || $0.server.lowercased().contains(query) + || $0.protection.lowercased().contains(query) + || $0.value.lowercased().contains(query) + } + } + return list.sorted(using: sortOrder) + } + + var statusText: String { + let count = filteredItems.count + let total = items.count + let suffix = count == 1 ? "item" : "items" + if count != total { + return "\(count)/\(total) \(suffix)" + } + if count == 0, !diagnostics.isEmpty { + return diagnostics.joined(separator: " | ") + } + return "\(count) \(suffix)" + } + + static let classFilters: [(label: String, value: String?)] = [ + ("All", nil), + ("Passwords", "genp"), + ("Internet", "inet"), + ("Certificates", "cert"), + ("Keys", "keys"), + ("Identities", "idnt"), + ] + + // MARK: - Actions + + func addTestItem() async { + do { + _ = try await control.addKeychainItem() + print("[keychain] test item added, refreshing...") + await refresh() + } catch { + self.error = "Add failed: \(error)" + print("[keychain] add failed: \(error)") + } + } + + // MARK: - Refresh + + func refresh() async { + guard control.isConnected else { + error = "Waiting for vphoned connection…" + return + } + isLoading = true + error = nil + do { + let result = try await control.listKeychainItems() + items = result.items.enumerated().compactMap { VPhoneKeychainItem(index: $0.offset, entry: $0.element) } + diagnostics = result.diagnostics + if items.isEmpty, !diagnostics.isEmpty { + print("[keychain] 0 items, diag: \(diagnostics)") + } + } catch { + self.error = "\(error)" + items = [] + } + isLoading = false + } +} diff --git a/sources/vphone-cli/VPhoneKeychainBrowserView.swift b/sources/vphone-cli/VPhoneKeychainBrowserView.swift new file mode 100644 index 0000000..c8fcaf6 --- /dev/null +++ b/sources/vphone-cli/VPhoneKeychainBrowserView.swift @@ -0,0 +1,297 @@ +import SwiftUI + +struct VPhoneKeychainBrowserView: View { + @Bindable var model: VPhoneKeychainBrowserModel + + private let controlBarHeight: CGFloat = 24 + + var body: some View { + VStack(spacing: 0) { + tableView + .padding(.bottom, controlBarHeight) + .overlay(controlBar.frame(maxHeight: .infinity, alignment: .bottom)) + .searchable(text: $model.searchText, prompt: "Filter keychain items") + .toolbar { toolbarContent } + + if model.showDiagnostics { + Divider() + diagnosticsPanel + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { await model.refresh() } + .onChange(of: model.control.isConnected) { _, connected in + if connected, model.items.isEmpty { + Task { await model.refresh() } + } + } + .alert( + "Error", + isPresented: .init( + get: { model.error != nil }, + set: { if !$0 { model.error = nil } } + ) + ) { + Button("OK") { model.error = nil } + } message: { + Text(model.error ?? "") + } + } + + // MARK: - Table + + var tableView: some View { + Table(of: VPhoneKeychainItem.self, selection: $model.selection, sortOrder: $model.sortOrder) { + TableColumn("", value: \.itemClass) { item in + Image(systemName: item.classIcon) + .foregroundStyle(.secondary) + .frame(width: 20) + .help(item.displayClass) + } + .width(28) + + TableColumn("Class", value: \.itemClass) { item in + Text(item.displayClass) + .font(.system(.body, design: .monospaced)) + } + .width(min: 60, ideal: 80, max: 100) + + TableColumn("Account", value: \.account) { item in + Text(item.account.isEmpty ? "—" : item.account) + .lineLimit(1) + .help(item.account) + } + .width(min: 80, ideal: 150, max: .infinity) + + TableColumn("Service", value: \.service) { item in + Text(item.service.isEmpty ? "—" : item.service) + .lineLimit(1) + .help(item.service) + } + .width(min: 80, ideal: 150, max: .infinity) + + TableColumn("Access Group", value: \.accessGroup) { item in + Text(item.accessGroup.isEmpty ? "—" : item.accessGroup) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .help(item.accessGroup) + } + .width(min: 80, ideal: 160, max: .infinity) + + TableColumn("Protection", value: \.protection) { item in + Text(item.protection.isEmpty ? "—" : item.protection) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .help(item.protectionDescription) + } + .width(min: 40, ideal: 60, max: 80) + + TableColumn("Value", value: \.value) { item in + Text(item.displayValue) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .help(item.displayValue) + } + .width(min: 60, ideal: 120, max: .infinity) + + TableColumn("Modified", value: \.displayName) { item in + Text(item.displayDate) + } + .width(min: 80, ideal: 120, max: .infinity) + } rows: { + ForEach(model.filteredItems) { item in + TableRow(item) + } + } + .contextMenu(forSelectionType: VPhoneKeychainItem.ID.self) { ids in + contextMenu(for: ids) + } + } + + // MARK: - Control Bar + + var controlBar: some View { + HStack(spacing: 6) { + Circle() + .fill(model.control.isConnected ? Color.green : Color.orange) + .frame(width: 8, height: 8) + + Divider() + + Picker("Class", selection: $model.filterClass) { + ForEach(VPhoneKeychainBrowserModel.classFilters, id: \.value) { filter in + Text(filter.label).tag(filter.value) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(maxWidth: 120) + + Divider() + + Text(model.statusText) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(minWidth: 60) + + Spacer() + + if model.isLoading { + ProgressView() + .controlSize(.small) + } + } + .padding(.horizontal, 8) + .frame(height: controlBarHeight) + .frame(maxWidth: .infinity) + .background(.bar) + } + + // MARK: - Diagnostics Panel + + var diagnosticsPanel: some View { + VStack(spacing: 0) { + HStack { + Text("Diagnostics") + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundStyle(.secondary) + + Spacer() + + Text("\(model.diagnostics.count) entries") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + + Button { + let text = model.diagnostics.joined(separator: "\n") + if !text.isEmpty { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + } label: { + Image(systemName: "doc.on.doc") + .font(.system(size: 10)) + } + .buttonStyle(.borderless) + .help("Copy diagnostics to clipboard") + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.bar) + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 1) { + ForEach(Array(model.diagnostics.enumerated()), id: \.offset) { index, line in + Text(line) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 1) + .id(index) + } + } + } + .onChange(of: model.diagnostics.count) { _, newCount in + if newCount > 0 { + proxy.scrollTo(newCount - 1, anchor: .bottom) + } + } + } + .frame(height: 140) + .background(Color(nsColor: .textBackgroundColor).opacity(0.5)) + } + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + var toolbarContent: some ToolbarContent { + ToolbarItem { + Button { + Task { await model.refresh() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .keyboardShortcut("r", modifiers: .command) + } + ToolbarItem { + Button { + Task { await model.addTestItem() } + } label: { + Label("Add Test", systemImage: "plus.circle") + } + .help("Add a test keychain item (debug)") + } + ToolbarItem { + Button { + copySelected() + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .disabled(model.selection.isEmpty) + } + ToolbarItem { + Button { + model.showDiagnostics.toggle() + } label: { + Label("Diagnostics", systemImage: model.showDiagnostics ? "ladybug.fill" : "ladybug") + } + .help("Toggle diagnostics log panel") + } + } + + // MARK: - Context Menu + + @ViewBuilder + func contextMenu(for ids: Set) -> some View { + Button("Copy Account") { copyField(ids: ids, keyPath: \.account) } + Button("Copy Service") { copyField(ids: ids, keyPath: \.service) } + Button("Copy Value") { copyField(ids: ids, keyPath: \.value) } + Button("Copy Access Group") { copyField(ids: ids, keyPath: \.accessGroup) } + Button("Copy Protection") { copyField(ids: ids, keyPath: \.protection) } + Divider() + Button("Copy Row (TSV)") { copyRows(ids: ids) } + Divider() + Button("Refresh") { Task { await model.refresh() } } + } + + // MARK: - Copy Actions + + func copySelected() { + let selected = model.filteredItems.filter { model.selection.contains($0.id) } + guard !selected.isEmpty else { return } + let header = "Class\tAccount\tService\tAccess Group\tProtection\tValue" + let rows = selected.map { item in + "\(item.displayClass)\t\(item.account)\t\(item.service)\t\(item.accessGroup)\t\(item.protection)\t\(item.displayValue)" + } + let text = ([header] + rows).joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + + func copyRows(ids: Set) { + let selected = model.filteredItems.filter { ids.contains($0.id) } + guard !selected.isEmpty else { return } + let header = "Class\tAccount\tService\tAccess Group\tProtection\tValue" + let rows = selected.map { item in + "\(item.displayClass)\t\(item.account)\t\(item.service)\t\(item.accessGroup)\t\(item.protection)\t\(item.displayValue)" + } + let text = ([header] + rows).joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + + func copyField(ids: Set, keyPath: KeyPath) { + let values = model.filteredItems + .filter { ids.contains($0.id) } + .map { $0[keyPath: keyPath] } + .filter { !$0.isEmpty } + .joined(separator: "\n") + guard !values.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(values, forType: .string) + } +} diff --git a/sources/vphone-cli/VPhoneKeychainItem.swift b/sources/vphone-cli/VPhoneKeychainItem.swift new file mode 100644 index 0000000..4a2a3f0 --- /dev/null +++ b/sources/vphone-cli/VPhoneKeychainItem.swift @@ -0,0 +1,133 @@ +import Foundation + +struct VPhoneKeychainItem: Identifiable, Hashable { + let id: String + let itemClass: String + let account: String + let service: String + let label: String + let accessGroup: String + let protection: String + let server: String + let value: String + let valueEncoding: String + let valueSize: Int + let created: Date? + let modified: Date? + + var displayClass: String { + switch itemClass { + case "genp": "Password" + case "inet": "Internet" + case "cert": "Certificate" + case "keys": "Key" + case "idnt": "Identity" + default: itemClass + } + } + + var classIcon: String { + switch itemClass { + case "genp": "key.fill" + case "inet": "globe" + case "cert": "checkmark.seal.fill" + case "keys": "lock.fill" + case "idnt": "person.badge.key.fill" + default: "questionmark.circle" + } + } + + var displayValue: String { + if value.isEmpty { return "—" } + if valueEncoding == "base64" { + return "[\(ByteCountFormatter.string(fromByteCount: Int64(valueSize), countStyle: .file)) binary]" + } + return value + } + + var displayName: String { + if !label.isEmpty { return label } + if !account.isEmpty { return account } + if !service.isEmpty { return service } + if !server.isEmpty { return server } + return "(unnamed)" + } + + var protectionDescription: String { + switch protection { + case "ak": "WhenUnlocked" + case "ck": "AfterFirstUnlock" + case "dk": "Always" + case "aku": "WhenUnlocked (ThisDevice)" + case "cku": "AfterFirstUnlock (ThisDevice)" + case "dku": "Always (ThisDevice)" + case "akpu": "WhenPasscodeSet (ThisDevice)" + default: protection + } + } + + var displayDate: String { + if let modified { + return Self.dateFormatter.string(from: modified) + } + if let created { + return Self.dateFormatter.string(from: created) + } + return "—" + } + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .short + f.timeStyle = .short + return f + }() + + private static let sqliteDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC") + return f + }() +} + +extension VPhoneKeychainItem { + init?(index: Int, entry: [String: Any]) { + guard let cls = entry["class"] as? String else { return nil } + + itemClass = cls + account = entry["account"] as? String ?? "" + service = entry["service"] as? String ?? "" + label = entry["label"] as? String ?? "" + accessGroup = entry["accessGroup"] as? String ?? "" + protection = entry["protection"] as? String ?? "" + server = entry["server"] as? String ?? "" + value = entry["value"] as? String ?? "" + valueEncoding = entry["valueEncoding"] as? String ?? "" + valueSize = (entry["valueSize"] as? NSNumber)?.intValue ?? 0 + + if let ts = entry["created"] as? Double { + created = Date(timeIntervalSince1970: ts) + } else if let ts = entry["created"] as? NSNumber { + created = Date(timeIntervalSince1970: ts.doubleValue) + } else if let str = entry["createdStr"] as? String { + created = Self.sqliteDateFormatter.date(from: str) + } else { + created = nil + } + + if let ts = entry["modified"] as? Double { + modified = Date(timeIntervalSince1970: ts) + } else if let ts = entry["modified"] as? NSNumber { + modified = Date(timeIntervalSince1970: ts.doubleValue) + } else if let str = entry["modifiedStr"] as? String { + modified = Self.sqliteDateFormatter.date(from: str) + } else { + modified = nil + } + + let rowid = (entry["_rowid"] as? NSNumber)?.intValue ?? index + id = "\(cls)-\(rowid)" + } +} diff --git a/sources/vphone-cli/VPhoneKeychainWindowController.swift b/sources/vphone-cli/VPhoneKeychainWindowController.swift new file mode 100644 index 0000000..15e2a4d --- /dev/null +++ b/sources/vphone-cli/VPhoneKeychainWindowController.swift @@ -0,0 +1,53 @@ +import AppKit +import SwiftUI + +@MainActor +class VPhoneKeychainWindowController { + private var window: NSWindow? + private var model: VPhoneKeychainBrowserModel? + + func showWindow(control: VPhoneControl) { + if let window { + window.makeKeyAndOrderFront(nil) + return + } + + let model = VPhoneKeychainBrowserModel(control: control) + self.model = model + + let view = VPhoneKeychainBrowserView(model: model) + let hostingView = NSHostingView(rootView: view) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 500), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = "Keychain" + window.subtitle = "vphone" + window.contentView = hostingView + window.contentMinSize = NSSize(width: 600, height: 300) + window.center() + window.toolbarStyle = .unified + window.isReleasedWhenClosed = false + + let toolbar = NSToolbar(identifier: "vphone-keychain-toolbar") + toolbar.displayMode = .iconOnly + window.toolbar = toolbar + + window.makeKeyAndOrderFront(nil) + self.window = window + + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.window = nil + self?.model = nil + } + } + } +} diff --git a/sources/vphone-cli/VPhoneMenuConnect.swift b/sources/vphone-cli/VPhoneMenuConnect.swift index 41c9d75..cd65d2f 100644 --- a/sources/vphone-cli/VPhoneMenuConnect.swift +++ b/sources/vphone-cli/VPhoneMenuConnect.swift @@ -6,12 +6,18 @@ extension VPhoneMenuController { func buildConnectMenu() -> NSMenuItem { let item = NSMenuItem() let menu = NSMenu(title: "Connect") + menu.autoenablesItems = false let fileBrowser = makeItem("File Browser", action: #selector(openFiles)) fileBrowser.isEnabled = false connectFileBrowserItem = fileBrowser menu.addItem(fileBrowser) + let keychainBrowser = makeItem("Keychain Browser", action: #selector(openKeychain)) + keychainBrowser.isEnabled = false + connectKeychainBrowserItem = keychainBrowser + menu.addItem(keychainBrowser) + menu.addItem(NSMenuItem.separator()) let devModeStatus = makeItem("Developer Mode Status", action: #selector(devModeStatus)) @@ -37,6 +43,7 @@ extension VPhoneMenuController { func updateConnectAvailability(available: Bool) { connectFileBrowserItem?.isEnabled = available + connectKeychainBrowserItem?.isEnabled = available connectDevModeStatusItem?.isEnabled = available connectPingItem?.isEnabled = available connectGuestVersionItem?.isEnabled = available @@ -46,6 +53,10 @@ extension VPhoneMenuController { onFilesPressed?() } + @objc func openKeychain() { + onKeychainPressed?() + } + @objc func devModeStatus() { Task { do { diff --git a/sources/vphone-cli/VPhoneMenuController.swift b/sources/vphone-cli/VPhoneMenuController.swift index 7abfb30..6f2b1cf 100644 --- a/sources/vphone-cli/VPhoneMenuController.swift +++ b/sources/vphone-cli/VPhoneMenuController.swift @@ -10,7 +10,9 @@ class VPhoneMenuController { weak var vm: VPhoneVirtualMachine? var onFilesPressed: (() -> Void)? + var onKeychainPressed: (() -> Void)? var connectFileBrowserItem: NSMenuItem? + var connectKeychainBrowserItem: NSMenuItem? var connectDevModeStatusItem: NSMenuItem? var connectPingItem: NSMenuItem? var connectGuestVersionItem: NSMenuItem?