diff --git a/MSG Desktop.xcodeproj/project.pbxproj b/MSG Desktop.xcodeproj/project.pbxproj index 592e5b0..1526d40 100644 --- a/MSG Desktop.xcodeproj/project.pbxproj +++ b/MSG Desktop.xcodeproj/project.pbxproj @@ -397,7 +397,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"MSG Desktop/Preview Content\""; - DEVELOPMENT_TEAM = C8Z9PRF4VH; + DEVELOPMENT_TEAM = RN94J52F92; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -424,7 +424,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"MSG Desktop/Preview Content\""; - DEVELOPMENT_TEAM = C8Z9PRF4VH; + DEVELOPMENT_TEAM = RN94J52F92; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/MSG Desktop/Assets.xcassets/AppStatusBar.imageset/1024.png b/MSG Desktop/Assets.xcassets/AppStatusBar.imageset/1024.png new file mode 100644 index 0000000..a3e22d5 Binary files /dev/null and b/MSG Desktop/Assets.xcassets/AppStatusBar.imageset/1024.png differ diff --git a/MSG Desktop/Assets.xcassets/AppStatusBar.imageset/512@1x.png b/MSG Desktop/Assets.xcassets/AppStatusBar.imageset/512@1x.png new file mode 100644 index 0000000..5fea9f7 Binary files /dev/null and b/MSG Desktop/Assets.xcassets/AppStatusBar.imageset/512@1x.png differ diff --git a/MSG Desktop/Assets.xcassets/AppStatusBar.imageset/512@2x.png b/MSG Desktop/Assets.xcassets/AppStatusBar.imageset/512@2x.png new file mode 100644 index 0000000..a3e22d5 Binary files /dev/null and b/MSG Desktop/Assets.xcassets/AppStatusBar.imageset/512@2x.png differ diff --git a/MSG Desktop/Assets.xcassets/AppStatusBar.imageset/Contents.json b/MSG Desktop/Assets.xcassets/AppStatusBar.imageset/Contents.json new file mode 100644 index 0000000..14e7fc5 --- /dev/null +++ b/MSG Desktop/Assets.xcassets/AppStatusBar.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "512@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "512@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "1024.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MSG Desktop/ContentView.swift b/MSG Desktop/ContentView.swift index 0f145da..dcaf585 100644 --- a/MSG Desktop/ContentView.swift +++ b/MSG Desktop/ContentView.swift @@ -7,131 +7,163 @@ import SwiftUI +struct ViewHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + struct ContentView: View { @ObservedObject var appState = AppState.shared private let maxLines = 300 @State private var showCancelButton: Bool = false @State private var showReinitPopup = false @State private var showCancelPopup = false + @State private var imageHeight: CGFloat = 0 var body: some View { - VStack { -// Image(systemName: "globe") -// .imageScale(.large) -// .foregroundStyle(.tint) - let appIcon = NSImage(named: NSImage.applicationIconName) - - // Use SwiftUI's Image to display the icon - Image(nsImage: appIcon ?? NSImage()) - .resizable() // Optional: if you want to resize the icon - .scaledToFit() // Optional: makes sure the aspect ratio is maintained + GeometryReader { geometry in + VStack(spacing: 10) { + // App icon + let appIcon = NSImage(named: NSImage.applicationIconName) + Image(nsImage: appIcon ?? NSImage()) + . resizable() + .scaledToFit() + .frame(maxHeight: 150) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: ViewHeightKey.self, value: proxy.size.height) + } + ) + .onPreferenceChange(ViewHeightKey.self) { height in + self.imageHeight = height + } - - ScrollViewReader { proxy in - ScrollView { - VStack(alignment: .leading) { - TextEditor(text: $appState.output) - .font(.system(.body, design: .monospaced)) - .frame(minWidth: 750, minHeight: 300) + ZStack { + Color.black + .ignoresSafeArea() + + // ScrollView fills remaining height + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + let lines = appState.output.components(separatedBy: .newlines) + + ForEach(lines.indices, id: \.self) { index in + Text(lines[index]) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .leading) + + } + + Color.clear.frame(height: 1).id("bottom") + } .padding() - .disabled(true) + } + .onChange(of: appState.output) { _, _ in + trimLinesIfNeeded() + withAnimation { + proxy.scrollTo("bottom", anchor: .bottom) + } + } - Text("") - .id("bottom") + // ScrollView { + // VStack(alignment: .leading) { + // TextEditor(text: $appState.output) + // .font(.system(.body, design: .monospaced)) + // .frame( + // width: geometry.size.width - 60, + // height: max(0, geometry.size.height - imageHeight - 120) // - rough height of button row and padding + // ) + // .padding() + // .disabled(true) + // + // Text("") + // .id("bottom") + // } + // } + // .onChange(of: appState.output) { _, _ in + // trimLinesIfNeeded() + // withAnimation { + // proxy.scrollTo("bottom", anchor: .bottom) + // } + // } } } - .onChange(of: appState.output) { _, _ in - trimLinesIfNeeded() - withAnimation { - proxy.scrollTo("bottom", anchor: .bottom) + + // Buttons + HStack { + Button("Init") { + DispatchQueue.main.async { self.showCancelButton = true } + runShellCommandAsync(command: "msg machine init", outputText: $appState.output, showButton: $showCancelButton) } - } - } - HStack { - Button(action: { - DispatchQueue.main.async { - self.showCancelButton = true + + Button("Reinit") { + showReinitPopup.toggle() } - runShellCommandAsync(command: "msg machine init", outputText: $appState.output, showButton: $showCancelButton) - - }) { - Text("Init") - } - - - Button(action: { - showReinitPopup.toggle() - }) { - Text("Reinit") - }.confirmationDialog("Are you sure?", isPresented: $showReinitPopup, titleVisibility: .visible) { - Button("Confirm", role: .destructive) { - DispatchQueue.main.async { + .confirmationDialog("Are you sure?", isPresented: $showReinitPopup, titleVisibility: .visible) { + Button("Confirm", role: .destructive) { self.showCancelButton = true runShellCommandAsync(command: "rm -rf ~/.guix && msg machine init", outputText: $appState.output, showButton: $showCancelButton) } - - } - Button("Cancel", role: .cancel) {} - } - - - Button(action: { - runShellCommandAsync(command: "msg machine start", outputText: $appState.output, showButton: nil) }) { - Text("Start") - } - if showCancelButton { - Button(action: { - showCancelPopup.toggle() - }) { - Text("Cancel") - }.confirmationDialog("Are you sure?", isPresented: $showCancelPopup, titleVisibility: .visible) { - Button("Confirm", role: .destructive) { - - runShellCommandAsync(command: "kill $(ps aux | grep '[g]uile' | awk '{print $2}')", outputText: $appState.output, showButton: nil) - runShellCommandAsync(command: "kill $(ps aux | grep '[q]emu' | awk '{print $2}')", outputText: $appState.output, showButton: nil) - - DispatchQueue.main.async { - - self.showCancelButton = false - } - } Button("Cancel", role: .cancel) {} } - } - - - if !showCancelButton{ - Button(action: { - runShellCommandAsync(command: "msg machine stop", outputText: $appState.output, showButton: nil) }) { - Text("Stop") + + Button("Start") { + runShellCommandAsync(command: "msg machine start", outputText: $appState.output, showButton: nil) + } + + if showCancelButton { + Button("Cancel") { + showCancelPopup.toggle() + } + .confirmationDialog("Are you sure?", isPresented: $showCancelPopup, titleVisibility: .visible) { + Button("Confirm", role: .destructive) { + runShellCommandAsync(command: "kill $(ps aux | grep '[g]uile' | awk '{print $2}')", outputText: $appState.output, showButton: nil) + runShellCommandAsync(command: "kill $(ps aux | grep '[q]emu' | awk '{print $2}')", outputText: $appState.output, showButton: nil) + DispatchQueue.main.async { + self.showCancelButton = false + } + } + Button("Cancel", role: .cancel) {} + } + } + + if !showCancelButton { + Button("Stop") { + runShellCommandAsync(command: "msg machine stop", outputText: $appState.output, showButton: nil) + } + } + + Button("Shell") { + runShellCommandAsync( + command: "osascript -e 'tell application \"Terminal\" to do script \"msg shell\"' -e 'tell application \"Terminal\" to activate'", + outputText: $appState.output, + showButton: nil + ) } } - - - Button(action: { - runShellCommandAsync(command: "osascript -e 'tell application \"Terminal\" to do script \"msg shell\"' -e 'tell application \"Terminal\" to activate'", outputText: $appState.output, showButton: nil) - - }) { - Text("Shell") - } } - - + .padding() + .frame(maxHeight: .infinity) + .onAppear { + // Silences error due to metallib on resizing of window + let device = MTLCreateSystemDefaultDevice() + _ = device?.makeDefaultLibrary() + } } - .padding() - .frame(width: 800, height: 400) } - private func trimLinesIfNeeded() { - let lines = AppState.shared.output.split(separator: "\n") - - if lines.count > maxLines { - _ = lines.count - maxLines - let trimmedLines = lines.suffix(maxLines) - AppState.shared.output = trimmedLines.joined(separator: "\n") - } + let lines = AppState.shared.output.split(separator: "\n") + if lines.count > maxLines { + let trimmedLines = lines.suffix(maxLines) + AppState.shared.output = trimmedLines.joined(separator: "\n") } + } } #Preview { diff --git a/MSG Desktop/MSG_Desktop.metal b/MSG Desktop/MSG_Desktop.metal new file mode 100644 index 0000000..1635f22 --- /dev/null +++ b/MSG Desktop/MSG_Desktop.metal @@ -0,0 +1,11 @@ +// +// MGS_Desktop.metal +// MSG Desktop +// +// Created by Etienne Roesch on 31/03/2025. +// + +#include +using namespace metal; + + diff --git a/MSG Desktop/MSG_DesktopApp.swift b/MSG Desktop/MSG_DesktopApp.swift index 2b28241..39d8c85 100644 --- a/MSG Desktop/MSG_DesktopApp.swift +++ b/MSG Desktop/MSG_DesktopApp.swift @@ -34,6 +34,7 @@ func runShellCommandAsync(command: String, outputText: Binding?, showBut var observer: NSObjectProtocol? + observer = NotificationCenter.default.addObserver(forName: .NSFileHandleDataAvailable, object: fileHandle, queue: nil) { _ in let data = fileHandle.availableData if let output = String(data: data, encoding: .utf8), !output.isEmpty { @@ -59,12 +60,14 @@ func runShellCommandAsync(command: String, outputText: Binding?, showBut } class AppDelegate: NSObject, NSApplicationDelegate { + var window: NSWindow! + func applicationWillTerminate(_ notification: Notification) { runShellCommandAsync(command: "msg machine stop", outputText: nil, showButton: nil) runShellCommandAsync(command: "kill $(ps aux | grep '[g]uile' | awk '{print $2}')", outputText: nil, showButton: nil) runShellCommandAsync(command: "kill $(ps aux | grep '[q]emu' | awk '{print $2}')", outputText: nil, showButton: nil) } - + func saveAppState() { print("App is terminating. Saving state...") UserDefaults.standard.set(Date(), forKey: "lastClosed") @@ -73,44 +76,61 @@ class AppDelegate: NSObject, NSApplicationDelegate { var statusBarItem: NSStatusItem! func applicationDidFinishLaunching(_ notification: Notification) { - statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - - if let button = statusBarItem.button { - button.title = "MSG" - button.toolTip = "MSG App" + statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + let contentView = ContentView() + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.center() + //window.setFrameAutosaveName("MSG App") // to retain size after quitting + window.contentView = NSHostingView(rootView: contentView) + window.makeKeyAndOrderFront(nil) + + if let button = statusBarItem.button { + //button.title = "MSG" + button.toolTip = "MSG App" + if let image = NSImage(named: "AppStatusBar") { + let newSize = NSSize(width: 18, height: 18) + image.size = newSize + image.isTemplate = true // Important: so it adapts to dark/light mode but we need to get B&W icons.. + button.image = image } - - let menu = NSMenu() - - menu.addItem(NSMenuItem(title: "Quit", action: #selector(quitApp), keyEquivalent: "q")) - - statusBarItem.menu = menu + button.target = self } - @objc func quitApp() { - let alert = NSAlert() - alert.messageText = "Are you sure you want to quit?" - alert.informativeText = "Do you want to quit the app and exit?" - alert.alertStyle = .warning - - alert.addButton(withTitle: "Quit") - alert.addButton(withTitle: "Cancel") - - let response = alert.runModal() - - if response == .alertFirstButtonReturn { - NSApplication.shared.terminate(nil) - } + let menu = NSMenu() + + menu.addItem(NSMenuItem(title: "Quit", action: #selector(quitApp), keyEquivalent: "q")) + + statusBarItem.menu = menu + } + + @objc func quitApp() { + let alert = NSAlert() + alert.messageText = "Are you sure you want to quit?" + alert.informativeText = "Do you want to quit the app and exit?" + alert.alertStyle = .warning + + alert.addButton(withTitle: "Quit") + alert.addButton(withTitle: "Cancel") + + let response = alert.runModal() + + if response == .alertFirstButtonReturn { + NSApplication.shared.terminate(nil) } + } } - - class AppState: ObservableObject { @Published var output: String = "Please select an option \n" - static let shared = AppState() + static let shared = AppState() } @main @@ -118,8 +138,6 @@ struct MSG_DesktopApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { - WindowGroup { - ContentView() - } + Settings {} // prevents crash on some macOS version } } diff --git a/README.org b/README.org index d520504..c68dbb1 100644 --- a/README.org +++ b/README.org @@ -1,4 +1,4 @@ -[[./example-gifs/init-example.gif]] +[[./doc/images/init-example.gif]] * Install from tap ** Add Tap @@ -10,3 +10,11 @@ brew tap MSG/apps https://forge.superkamiguru.org/MSG/homebrew-apps #+begin_src sh brew install msg-desktop #+end_src + +** Security & Privacy +The first time you run the app, you will need to allow it to run in the Security +& Privacy settings of your Mac. This is because the app is not signed by Apple. +You can do this by going to System Preferences > Security & Privacy > General +and clicking "Open Anyway" next to the message about the app being blocked. + +[[./doc/images/macos15.3.2-security.png]] diff --git a/doc/.DS_Store b/doc/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/doc/.DS_Store differ diff --git a/example-gifs/init-example.gif b/doc/images/init-example.gif similarity index 100% rename from example-gifs/init-example.gif rename to doc/images/init-example.gif diff --git a/doc/images/macos15.3.2-security.png b/doc/images/macos15.3.2-security.png new file mode 100644 index 0000000..923b4e8 Binary files /dev/null and b/doc/images/macos15.3.2-security.png differ