Skip to content

Commit a06cb90

Browse files
committed
AppMover, Launch behavior, no more bundled Epsilon
1 parent b8973f1 commit a06cb90

16 files changed

Lines changed: 1317 additions & 702 deletions

File tree

NumWorksMac/App/AppController.swift

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ final class AppController: NSObject, NSWindowDelegate {
99
private var menuBarController: MenuBarController?
1010
private var cancellables = Set<AnyCancellable>()
1111
private var settingsWindowOpen = false
12+
private var didRunOnLaunch = false
1213

1314
private var suppressFrameSaves = false
1415
private let windowFrameDefaultsKey = "MainWindowFrame"
@@ -17,22 +18,14 @@ final class AppController: NSObject, NSWindowDelegate {
1718
super.init()
1819
}
1920

20-
private func saveWindowFrame(_ window: NSWindow) {
21-
guard !suppressFrameSaves else { return }
22-
guard window.isVisible, !window.isMiniaturized else { return }
23-
UserDefaults.standard.set(NSStringFromRect(window.frame), forKey: windowFrameDefaultsKey)
24-
}
25-
26-
private func applyDockIconVisibility() {
27-
let show = settingsWindowOpen ? true : Preferences.shared.showDockIcon
28-
let policy: NSApplication.ActivationPolicy = show ? .regular : .accessory
29-
30-
if NSApp.activationPolicy() != policy {
31-
_ = NSApp.setActivationPolicy(policy)
21+
func start() {
22+
print("[App] start()")
23+
OnLaunch.ensureAppSupportExists()
24+
let hasSim = OnLaunch.hasInstalledSimulator()
25+
if hasSim {
26+
print("[App] simulator detected in Application Support")
3227
}
33-
}
3428

35-
func start() {
3629
updateMenuBarIcon()
3730
applyDockIconVisibility()
3831

@@ -44,12 +37,31 @@ final class AppController: NSObject, NSWindowDelegate {
4437
self?.togglePinned()
4538
}
4639

40+
if !hasSim {
41+
print("[App] no installed simulator → keeping menu bar icon, blocking calculator window")
42+
Preferences.shared.isAppVisible = false
43+
OnLaunch.requestRequiredSimulatorUpdater()
44+
return
45+
46+
}
47+
4748
windowManagement.setPinned(Preferences.shared.isPinned)
4849

4950
if Preferences.shared.isAppVisible {
5051
windowManagement.show()
5152
}
5253

54+
NotificationCenter.default.publisher(for: .calculatorDidLoad)
55+
.receive(on: RunLoop.main)
56+
.sink { [weak self] _ in
57+
guard let self else { return }
58+
guard !self.didRunOnLaunch else { return }
59+
self.didRunOnLaunch = true
60+
print("[OnLaunch] calculatorDidLoad received → running OnLaunch")
61+
OnLaunch.run()
62+
}
63+
.store(in: &cancellables)
64+
5365
Preferences.shared.$isMenuBarIconEnabled
5466
.removeDuplicates()
5567
.receive(on: RunLoop.main)
@@ -116,12 +128,30 @@ final class AppController: NSObject, NSWindowDelegate {
116128
.store(in: &cancellables)
117129
}
118130

131+
private func saveWindowFrame(_ window: NSWindow) {
132+
guard !suppressFrameSaves else { return }
133+
guard window.isVisible, !window.isMiniaturized else { return }
134+
UserDefaults.standard.set(NSStringFromRect(window.frame), forKey: windowFrameDefaultsKey)
135+
}
136+
137+
private func applyDockIconVisibility() {
138+
let show = settingsWindowOpen ? true : Preferences.shared.showDockIcon
139+
let policy: NSApplication.ActivationPolicy = show ? .regular : .accessory
140+
141+
if NSApp.activationPolicy() != policy {
142+
_ = NSApp.setActivationPolicy(policy)
143+
}
144+
}
145+
119146
private func updateMenuBarIcon() {
147+
print("[MenuBar] updateMenuBarIcon enabled=\(Preferences.shared.isMenuBarIconEnabled)")
120148
if Preferences.shared.isMenuBarIconEnabled {
149+
print("[MenuBar] creating status item")
121150
if menuBarController == nil {
122151
menuBarController = MenuBarController(wm: windowManagement)
123152
}
124153
} else {
154+
print("[MenuBar] removing status item")
125155
menuBarController?.invalidate()
126156
menuBarController = nil
127157
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//
2+
// AppMover.swift
3+
// AppMover
4+
//
5+
// Created by Oskar Groth on 2019-12-20.
6+
// Copyright © 2019 Oskar Groth. All rights reserved.
7+
//
8+
9+
import AppKit
10+
import Security
11+
12+
public enum AppMover {
13+
14+
public static func moveIfNecessary() {
15+
let fm = FileManager.default
16+
guard !Bundle.main.isInstalled,
17+
let applications = preferredInstallDirectory() else { return }
18+
let bundleUrl = Bundle.main.bundleURL
19+
let bundleName = bundleUrl.lastPathComponent
20+
let destinationUrl = applications.appendingPathComponent(bundleName)
21+
let needDestAuth = fm.fileExists(atPath: destinationUrl.path) && !fm.isWritableFile(atPath: destinationUrl.path)
22+
let needAuth = needDestAuth || !fm.isWritableFile(atPath: applications.path)
23+
24+
// Activate app -- work-around for focus issues related to "scary file from
25+
// internet" OS dialog.
26+
if !NSApp.isActive {
27+
NSApp.activate(ignoringOtherApps: true)
28+
}
29+
30+
let alert = NSAlert()
31+
alert.messageText = "Move to Applications folder"
32+
alert.informativeText = "\(Bundle.main.localizedName) needs to move to your Applications folder in order to work properly."
33+
if needAuth {
34+
alert.informativeText.append(" You need to authenticate with your administrator password to complete this step.")
35+
}
36+
alert.addButton(withTitle: "Move to Applications Folder")
37+
alert.addButton(withTitle: "Do Not Move")
38+
guard alert.runModal() == .alertFirstButtonReturn else {
39+
return
40+
}
41+
if needAuth {
42+
let result = authorizedInstall(from: bundleUrl, to: destinationUrl)
43+
guard !result.cancelled else { moveIfNecessary(); return }
44+
guard result.success else {
45+
NSApplication.shared.terminate(self)
46+
return
47+
}
48+
} else {
49+
if fm.fileExists(atPath: destinationUrl.path) {
50+
if AppMover.isApplicationAtUrlRunning(destinationUrl) {
51+
NSWorkspace.shared.open(destinationUrl)
52+
return
53+
} else {
54+
guard (try? fm.trashItem(at: destinationUrl, resultingItemURL: nil)) != nil else {
55+
return
56+
}
57+
}
58+
}
59+
guard (try? fm.copyItem(at: bundleUrl, to: destinationUrl)) != nil else {
60+
return
61+
}
62+
}
63+
64+
// Trash the original app
65+
_ = try? fm.removeItem(at: bundleUrl)
66+
67+
relaunch(at: destinationUrl.path, completionCallback: {
68+
DispatchQueue.main.async {
69+
exit(0)
70+
}
71+
})
72+
73+
}
74+
75+
static func authorizedInstall(from sourceURL: URL, to destinationURL: URL) -> (cancelled: Bool, success: Bool) {
76+
guard destinationURL.representsBundle,
77+
destinationURL.isValid,
78+
sourceURL.isValid else {
79+
return (false, false)
80+
}
81+
return sourceURL.withUnsafeFileSystemRepresentation({ sourcePath -> (cancelled: Bool, success: Bool) in
82+
return destinationURL.withUnsafeFileSystemRepresentation({ destinationPath -> (cancelled: Bool, success: Bool) in
83+
guard let sourcePath = sourcePath, let destinationPath = destinationPath else { return (false, false) }
84+
let deleteCommand = "rm -rf '\(String(cString: destinationPath))'"
85+
let copyCommand = "cp -pR '\(String(cString: sourcePath))' '\(String(cString: destinationPath))'"
86+
guard let script = NSAppleScript(source: "do shell script \"\(deleteCommand) && \(copyCommand)\" with administrator privileges") else {
87+
return (false, false)
88+
}
89+
var error: NSDictionary?
90+
script.executeAndReturnError(&error)
91+
return ((error?[NSAppleScript.errorNumber] as? Int16) == -128, error == nil)
92+
})
93+
})
94+
}
95+
96+
static func preferredInstallDirectory() -> URL? {
97+
let fm = FileManager.default
98+
let dirs = fm.urls(for: .applicationDirectory, in: .allDomainsMask)
99+
// Find Applications dir with the most apps that isn't system protected
100+
return dirs.map({ $0.resolvingSymlinksInPath() }).filter({ url in
101+
var isDir: ObjCBool = false
102+
fm.fileExists(atPath: url.path, isDirectory: &isDir)
103+
return isDir.boolValue && url.path != "/System/Applications"
104+
}).sorted(by: { left, right in
105+
return left.numberOfFilesInDirectory < right.numberOfFilesInDirectory
106+
}).last
107+
}
108+
109+
static func isApplicationAtUrlRunning(_ url: URL) -> Bool {
110+
let url = url.standardized
111+
return NSWorkspace.shared.runningApplications.contains(where: {
112+
$0.bundleURL?.standardized == url
113+
})
114+
}
115+
116+
public static func relaunch(at path: String, completionCallback: @escaping () -> Void) {
117+
let pid = ProcessInfo.processInfo.processIdentifier
118+
Process.runTask(command: "/usr/bin/xattr", arguments: ["-d", "-r", "com.apple.quarantine", path], completion: { _ in
119+
let waitForExitScript = "(while /bin/kill -0 \(pid) >&/dev/null; do /bin/sleep 0.1; done; /usr/bin/open \"\(path)\") &"
120+
Process.runTask(command: "/bin/sh", arguments: ["-c", waitForExitScript])
121+
completionCallback()
122+
})
123+
}
124+
125+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//
2+
// Extensions.swift
3+
// AppMover
4+
//
5+
// Created by Oskar Groth on 2019-12-22.
6+
// Copyright © 2019 Oskar Groth. All rights reserved.
7+
//
8+
9+
import Cocoa
10+
import Foundation
11+
12+
extension URL {
13+
14+
var representsBundle: Bool {
15+
pathExtension == "app"
16+
}
17+
18+
var isValid: Bool {
19+
!path.trimmingCharacters(in: .whitespaces).isEmpty
20+
}
21+
22+
var numberOfFilesInDirectory: Int {
23+
(try? FileManager.default.contentsOfDirectory(atPath: path))?.count ?? 0
24+
}
25+
26+
}
27+
28+
extension Bundle {
29+
30+
var localizedName: String {
31+
NSRunningApplication.current.localizedName ?? "The App"
32+
}
33+
34+
var isInstalled: Bool {
35+
NSSearchPathForDirectoriesInDomains(.applicationDirectory, .allDomainsMask, true).contains(where: { $0.hasPrefix(bundlePath)
36+
}) || bundlePath.split(separator: "/").contains("Applications")
37+
}
38+
39+
func copy(to url: URL) throws {
40+
try FileManager.default.copyItem(at: bundleURL, to: url)
41+
}
42+
43+
}
44+
45+
extension Process {
46+
47+
static func runTask(command: String, arguments: [String] = [], completion: ((Int32) -> Void)? = nil) {
48+
let task = Process()
49+
task.launchPath = command
50+
task.arguments = arguments
51+
task.terminationHandler = { task in
52+
completion?(task.terminationStatus)
53+
}
54+
task.launch()
55+
}
56+
57+
}

0 commit comments

Comments
 (0)