Files
luma/Luma/CollaborationPanel.swift
Ole André Vadla Ravnås 0e933206fb Boot virtual machines from the target picker
Add a machine abstraction to LumaCore, with QEMU as its first
backend: templates mirroring frida-core's test guests, a QMP client,
and a display that maps QEMU's surface over D-Bus rather than
copying frames.

Leave readiness to the user: drive the guest, mark it ready, and come
back to that snapshot later. Keep the machine in the project, so
booting it again resumes where it was marked. Hand the stub to
addBareboneDevice, so it joins the picker beside the other devices.

Show the display while booting, and afterwards in a Machines panel,
which the side panel now models as an enum to share its slot.
2026-08-20 14:52:52 +02:00

832 lines
30 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Frida
import LumaCore
import SwiftUI
import UniformTypeIdentifiers
#if canImport(AppKit)
import AppKit
#endif
#if canImport(UIKit)
import UIKit
import PhotosUI
#endif
struct CollaborationPanel: View {
let engine: Engine
@State private var didCopyInvite = false
@State private var showLeaveLabConfirmation = false
@State private var focusChatField = false
@State private var isPinnedToBottom = true
private let chatBottomID = "CHAT_BOTTOM_ANCHOR"
#if canImport(UIKit)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var isCompactWidth: Bool { horizontalSizeClass == .compact }
#else
private var isCompactWidth: Bool { false }
#endif
private var collaboration: CollaborationSession {
engine.collaboration
}
private var isActive: Bool {
if case .joined = collaboration.status { return true }
return false
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
if !isCompactWidth {
header
Divider()
}
labSection
if isActive {
Divider()
participantsSection
Divider()
chatSection
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(12)
.background(.ultraThickMaterial)
.sheet(
isPresented: Binding(
get: { engine.gitHubAuth.isPresentingSignIn },
set: { if !$0 { engine.gitHubAuth.dismissSignIn() } }
)
) {
GitHubSignInSheet(auth: engine.gitHubAuth)
}
.onAppear {
if case .joined = collaboration.status {
focusChatField = true
}
}
.alert("Leave this lab?", isPresented: $showLeaveLabConfirmation) {
Button("Leave", role: .destructive) {
Task { @MainActor in
await engine.collaboration.leaveLab()
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("You'll lose access to the lab's shared state. The lab keeps going for everyone else.")
}
}
private var header: some View {
HStack {
Image(systemName: "person.2.wave.2")
Text("Collaboration")
.font(.headline)
Spacer()
if let user = engine.gitHubAuth.currentUser {
Menu {
Button("View GitHub Profile") {
openGitHubProfile(for: user)
}
Button("Sign out", role: .destructive) {
Task { @MainActor in
await engine.gitHubAuth.signOut()
await engine.collaboration.stop()
}
}
} label: {
AsyncImage(url: avatarSizeURL(user, size: 20)) { image in
image.resizable().scaledToFill()
} placeholder: {
Image(systemName: "person.crop.circle")
}
.frame(width: 20, height: 20)
.clipShape(Circle())
}
.menuStyle(.borderlessButton)
}
Button {
engine.setSidePanel(nil)
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 14))
}
.buttonStyle(.plain)
}
}
private var labSection: some View {
labSectionContent
.frame(maxWidth: .infinity, alignment: .leading)
}
private var labSectionContent: some View {
VStack(alignment: .leading, spacing: 8) {
switch collaboration.status {
case .disconnected:
let storedLabID = (try? engine.store.fetchCollaborationState())?.labID
let hasExistingLab = storedLabID != nil
if let stored = storedLabID {
Text("You're currently offline from the shared lab (lab \(truncatedLabID(stored))).")
.font(.caption)
.foregroundStyle(.secondary)
Text("Reconnect to rejoin and resume syncing.")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.top, 2)
} else {
Text("Collaboration is currently off for this project.")
.font(.caption)
.foregroundStyle(.secondary)
Text("Enable collaboration to share this project's state with teammates, including chat and presence.")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.top, 2)
}
let actionLabel: String = {
if hasExistingLab {
return "Reconnect"
}
if let user = engine.gitHubAuth.currentUser {
return "Enable collaboration as @\(user.id)"
}
return "Enable collaboration"
}()
Button(actionLabel) {
engine.startCollaboration()
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("collaboration.enable")
case .connecting:
HStack(spacing: 6) {
ProgressView()
.scaleEffect(0.7)
Text("Connecting…")
.font(.caption)
.foregroundStyle(.secondary)
}
case .joined:
if let labID = collaboration.labID {
HStack(alignment: .center, spacing: 10) {
LabPictureView(collaboration: collaboration)
LabTitleView(collaboration: collaboration)
}
HStack {
Text(collaboration.isHost ? "You are hosting this lab." : "You joined this lab.")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
labOverflowMenu
}
let inviteURL = "\(BackendConfig.inviteLinkBase)\(labID)"
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text("Invite link:")
.font(.caption2).bold()
Text(inviteURL)
.font(.caption2)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
.accessibilityIdentifier("collaboration.inviteLink")
Button {
Platform.copyToClipboard(inviteURL)
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
didCopyInvite = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) {
withAnimation(.easeOut(duration: 0.2)) {
didCopyInvite = false
}
}
} label: {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.borderless)
.help("Copy invite link")
.accessibilityLabel("Copy invite link")
}
Text("Share this link to invite others to this lab.")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.top, 2)
.fixedSize(horizontal: false, vertical: true)
}
.overlay(alignment: .top) {
if didCopyInvite {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
Text("Copied!")
.font(.callout.weight(.medium))
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.ultraThickMaterial)
.clipShape(Capsule())
.shadow(radius: 3, y: 2)
.transition(.move(edge: .top).combined(with: .opacity))
.accessibilityHidden(true)
.padding(.top, -6)
}
}
} else {
Text("Collaboration is active for this project.")
.font(.caption)
.foregroundStyle(.secondary)
}
case .error(let msg):
Label(msg, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.yellow)
Button("Retry") {
engine.startCollaboration()
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("collaboration.retry")
}
}
}
private func truncatedLabID(_ id: String) -> String {
if id.count <= 8 { return id }
let prefix = id.prefix(4)
let suffix = id.suffix(4)
return "\(prefix)…\(suffix)"
}
private var labOverflowMenu: some View {
Menu {
Button("Leave lab", role: .destructive) {
showLeaveLabConfirmation = true
}
Button("Disconnect from lab") {
Task { @MainActor in
await engine.collaboration.stop()
}
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 14))
}
.menuStyle(.borderlessButton)
.fixedSize()
.help("Lab actions")
}
private var participantsSection: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Members")
.font(.subheadline).bold()
Spacer()
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(sortedMembers) { member in
memberAvatarView(for: member)
}
}
.padding(.vertical, 2)
}
.frame(minHeight: 32)
}
}
private var sortedMembers: [CollaborationSession.Member] {
collaboration.members.sorted { a, b in
if (a.role == .owner) != (b.role == .owner) { return a.role == .owner }
if (a.presence == .online) != (b.presence == .online) {
return a.presence == .online
}
return a.joinedAt < b.joinedAt
}
}
private func memberAvatarView(for member: CollaborationSession.Member) -> some View {
let online = member.presence == .online
let isOwner = member.role == .owner
return avatarView(for: member.user)
.opacity(online ? 1.0 : 0.55)
.overlay(alignment: .topTrailing) {
if isOwner {
Image(systemName: "crown.fill")
.font(.system(size: 9))
.foregroundStyle(Color(red: 0.94, green: 0.39, blue: 0.34))
.padding(2)
.background(Circle().fill(.background))
.offset(x: 4, y: -4)
}
}
.overlay(alignment: .bottomTrailing) {
Circle()
.fill(online ? Color(red: 0.36, green: 0.78, blue: 0.41) : Color.secondary.opacity(0.6))
.frame(width: 8, height: 8)
.overlay(Circle().stroke(Color.platformWindowBackground, lineWidth: 1.5))
.offset(x: 2, y: 2)
}
.help(memberTooltip(member))
.contextMenu {
memberContextMenu(for: member)
}
}
private func memberTooltip(_ member: CollaborationSession.Member) -> String {
let role = member.role == .owner ? "owner" : "member"
let presence = member.presence == .online ? "online" : "offline"
return "\(member.user.name) · \(role) · \(presence)"
}
@ViewBuilder
private func memberContextMenu(for member: CollaborationSession.Member) -> some View {
if collaboration.isOwner, !collaboration.isSelf(member.user.id) {
let blockedByLastOwner = (member.role == .owner && ownerCount == 1)
if member.role == .member {
Button("Promote to owner") {
Task { @MainActor in
await collaboration.setMemberRole(userID: member.user.id, role: .owner)
}
}
} else {
Button("Demote to member") {
Task { @MainActor in
await collaboration.setMemberRole(userID: member.user.id, role: .member)
}
}
.disabled(blockedByLastOwner)
}
Divider()
Button("Remove from lab", role: .destructive) {
Task { @MainActor in
await collaboration.removeMembers([member.user.id])
}
}
.disabled(blockedByLastOwner)
}
}
private var ownerCount: Int {
collaboration.members.reduce(0) { $0 + ($1.role == .owner ? 1 : 0) }
}
private var chatSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Chat")
.font(.subheadline).bold()
GeometryReader { outer in
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 4) {
ForEach(collaboration.chatMessages) { msg in
HStack(alignment: .top, spacing: 4) {
if msg.isLocal {
Spacer()
}
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
avatarView(for: msg.sender)
Text("@\(msg.sender.id)")
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
RelativeTimeText(
date: msg.timestamp,
color: .secondary.opacity(0.7)
)
.help(msg.timestamp.formatted())
}
Text(msg.text)
.font(.caption)
}
.padding(6)
.background(
msg.isLocal
? Color.accentColor.opacity(0.35)
: Color.secondary.opacity(0.1)
)
.clipShape(RoundedRectangle(cornerRadius: 6))
if !msg.isLocal {
Spacer()
}
}
}
Color.clear
.frame(height: 0)
.id(chatBottomID)
}
.frame(minHeight: outer.size.height, alignment: .bottomLeading)
}
.scrollBounceBehavior(.basedOnSize)
.scrollDismissesKeyboard(.interactively)
.coordinateSpace(name: "chatScroll")
.simultaneousGesture(
DragGesture().onChanged { _ in
isPinnedToBottom = false
}
)
.onChange(of: collaboration.chatMessages.count) { _, _ in
if isPinnedToBottom {
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo(chatBottomID, anchor: .bottom)
}
}
}
.onAppear {
proxy.scrollTo(chatBottomID, anchor: .bottom)
}
}
}
.frame(minHeight: 80)
ChatInputRow(engine: engine, isFocused: $focusChatField)
.onChange(of: focusChatField) { _, newVal in
if newVal {
isPinnedToBottom = true
}
}
}
}
private func avatarSizeURL(_ user: CollaborationSession.UserInfo, size: Int) -> URL? {
guard let base = user.avatarURL else { return nil }
return URL(string: "\(base.absoluteString)&s=\(size)")
}
private func avatarView(for user: CollaborationSession.UserInfo) -> some View {
Group {
if let url = avatarSizeURL(user, size: 40) {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
ProgressView()
.scaleEffect(0.5)
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
Image(systemName: "person.fill")
.resizable()
.scaledToFit()
.padding(4)
@unknown default:
Image(systemName: "person.fill")
.resizable()
.scaledToFit()
.padding(4)
}
}
} else {
Image(systemName: "person.fill")
.resizable()
.scaledToFit()
.padding(4)
}
}
.frame(width: 22, height: 22)
.clipShape(Circle())
.overlay(
Circle()
.strokeBorder(Color.secondary.opacity(0.3), lineWidth: 0.5)
)
.help(user.name)
.onTapGesture {
openGitHubProfile(for: user)
}
}
private func openGitHubProfile(for user: CollaborationSession.UserInfo) {
let urlString = "https://github.com/\(user.id)"
guard let url = URL(string: urlString) else { return }
Platform.openURL(url)
}
}
private struct ChatInputRow: View {
let engine: Engine
@Binding var isFocused: Bool
@State private var draft: String = ""
@FocusState private var textFieldFocused: Bool
private var collaboration: CollaborationSession {
engine.collaboration
}
private var isActive: Bool {
if case .joined = collaboration.status { return true }
return false
}
var body: some View {
HStack {
TextField("Message", text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.focused($textFieldFocused)
.onSubmit {
send()
}
Button {
send()
} label: {
Image(systemName: "paperplane.fill")
}
.disabled(!canSend)
}
.font(.caption)
.onAppear {
if isFocused {
DispatchQueue.main.async {
textFieldFocused = true
}
}
}
.onChange(of: isFocused) { _, newValue in
if newValue {
DispatchQueue.main.async {
textFieldFocused = true
}
}
}
.onChange(of: textFieldFocused) { _, newValue in
if isFocused != newValue {
isFocused = newValue
}
}
}
private var canSend: Bool {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && isActive
}
private func send() {
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, isActive else { return }
draft = ""
Task {
do {
try await collaboration.sendChat(text)
} catch {
draft = text
}
}
}
}
private struct LabPictureView: View {
var collaboration: CollaborationSession
private static let supportedTypes: [(contentType: String, uti: String)] = [
("image/png", "public.png"),
("image/jpeg", "public.jpeg"),
("image/webp", "org.webmproject.webp"),
("image/gif", "com.compuserve.gif"),
]
private static let pictureSize: CGFloat = 48
#if canImport(UIKit)
@State private var photoSelection: PhotosPickerItem?
@State private var isShowingPhotoPicker = false
#endif
var body: some View {
Group {
#if canImport(AppKit)
if collaboration.isOwner {
Button(action: pickImage) {
pictureView
}
.buttonStyle(.plain)
.help("Change lab picture")
} else {
pictureView
}
#elseif canImport(UIKit)
if collaboration.isOwner {
Button {
isShowingPhotoPicker = true
} label: {
pictureView
}
.buttonStyle(.plain)
.photosPicker(
isPresented: $isShowingPhotoPicker,
selection: $photoSelection,
matching: .images,
photoLibrary: .shared()
)
.onChange(of: photoSelection) { _, newValue in
guard let newValue else { return }
Task { await loadAndUpload(newValue) }
}
} else {
pictureView
}
#else
pictureView
#endif
}
}
@ViewBuilder
private var pictureView: some View {
pictureContent
.frame(width: Self.pictureSize, height: Self.pictureSize)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
@ViewBuilder
private var pictureContent: some View {
if let data = collaboration.labPictureData,
let image = Self.loadImage(from: data) {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else if let owner = collaboration.members.first(where: { $0.role == .owner }),
let url = owner.user.avatarURL.flatMap({
URL(string: "\($0.absoluteString)&s=\(Int(Self.pictureSize * 2))")
}) {
AsyncImage(url: url) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.secondary.opacity(0.1)
}
} else {
Color.secondary.opacity(0.15)
.overlay(Image(systemName: "photo").foregroundStyle(.secondary))
}
}
private static func loadImage(from data: Data) -> Image? {
Image(platformImageData: data)
}
#if canImport(AppKit)
private func pickImage() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowedContentTypes = Self.supportedTypes.compactMap {
UTType($0.uti)
}
guard panel.runModal() == .OK, let url = panel.url,
let data = try? Data(contentsOf: url) else { return }
let (bytes, contentType) = normalizedPicture(from: data, originalExtension: url.pathExtension)
Task { @MainActor in
await collaboration.setLabPicture(bytes, contentType: contentType)
}
}
/// Downscale the user-supplied image to at most 512×512 and re-
/// encode as JPEG. Keeps the wire payload well under the server's
/// 512 KiB cap, fits the UI's 48-pt display budget, and strips
/// any EXIF metadata the source file carried.
private func normalizedPicture(
from data: Data,
originalExtension ext: String,
) -> (Data, String) {
let maxDimension: CGFloat = 512
let passthrough: (Data, String) = {
switch ext.lowercased() {
case "png": return (data, "image/png")
case "jpg", "jpeg": return (data, "image/jpeg")
case "webp": return (data, "image/webp")
case "gif": return (data, "image/gif")
default: return (data, "image/png")
}
}()
guard let image = NSImage(data: data) else { return passthrough }
let size = image.size
let longest = max(size.width, size.height)
let scale = min(1, maxDimension / longest)
let target = CGSize(width: size.width * scale, height: size.height * scale)
let resized = NSImage(size: target)
resized.lockFocus()
NSGraphicsContext.current?.imageInterpolation = .high
image.draw(
in: NSRect(origin: .zero, size: target),
from: .zero,
operation: .copy,
fraction: 1.0,
)
resized.unlockFocus()
guard let tiff = resized.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
let jpeg = bitmap.representation(
using: .jpeg,
properties: [.compressionFactor: 0.85]
)
else { return passthrough }
return (jpeg, "image/jpeg")
}
#endif
#if canImport(UIKit)
private func loadAndUpload(_ item: PhotosPickerItem) async {
defer { Task { @MainActor in photoSelection = nil } }
guard let data = try? await item.loadTransferable(type: Data.self) else { return }
let (bytes, contentType) = normalizedPicture(from: data)
await collaboration.setLabPicture(bytes, contentType: contentType)
}
private func normalizedPicture(from data: Data) -> (Data, String) {
let maxDimension: CGFloat = 512
guard let image = UIImage(data: data) else { return (data, "image/jpeg") }
let size = image.size
let longest = max(size.width, size.height)
let scale = min(1, maxDimension / longest)
let target = CGSize(width: size.width * scale, height: size.height * scale)
let format = UIGraphicsImageRendererFormat.default()
format.scale = 1
let renderer = UIGraphicsImageRenderer(size: target, format: format)
let resized = renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: target))
}
let jpeg = resized.jpegData(compressionQuality: 0.85) ?? data
return (jpeg, "image/jpeg")
}
#endif
}
private struct LabTitleView: View {
var collaboration: CollaborationSession
@State private var isEditing = false
@State private var draft: String = ""
@FocusState private var fieldFocused: Bool
var body: some View {
HStack(spacing: 6) {
if isEditing {
TextField("Title", text: $draft)
.textFieldStyle(.roundedBorder)
.font(.headline)
.focused($fieldFocused)
.onSubmit(commit)
Button("Save", action: commit)
.buttonStyle(.borderedProminent)
.controlSize(.small)
Button("Cancel") { isEditing = false }
.buttonStyle(.bordered)
.controlSize(.small)
} else {
Text(collaboration.labTitle ?? "Untitled")
.font(.headline)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
if collaboration.isOwner {
Button {
draft = collaboration.labTitle ?? ""
isEditing = true
DispatchQueue.main.async { fieldFocused = true }
} label: {
Image(systemName: "pencil")
}
.buttonStyle(.borderless)
.help("Rename lab")
}
}
}
}
private func commit() {
let trimmed = draft.trimmingCharacters(in: .whitespacesAndNewlines)
isEditing = false
guard !trimmed.isEmpty, trimmed != collaboration.labTitle else { return }
Task { @MainActor in
await collaboration.setLabTitle(trimmed)
}
}
}