Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
906f45b156 | ||
|
|
a8dfe6fb15 | ||
|
|
dd84561293 | ||
|
|
d2eac0f0d2 | ||
|
|
f94a56ec58 | ||
|
|
60d2a15f0f | ||
|
|
d26169f8b9 | ||
|
|
9920201fe2 | ||
|
|
acc0b03522 | ||
|
|
7510327139 | ||
|
|
0371e316fd | ||
|
|
86e33a871d | ||
|
|
733419c214 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
.DS_Store
|
||||
/*.app
|
||||
/*.tar.gz
|
||||
*.xcodeproj
|
||||
|
||||
49
Makefile
Executable file → Normal file
49
Makefile
Executable file → Normal file
@@ -6,21 +6,48 @@ else
|
||||
CFLAGS=-O
|
||||
endif
|
||||
|
||||
APP_DIR = Memmon.app/Contents
|
||||
PLIST=$(shell grep -A1 $(1) src/Info.plist | tail -1 | cut -d'>' -f2 | cut -d'<' -f1)
|
||||
HAS_SIGN_IDENTITY=$(shell security find-identity -v -p codesigning | grep -q "Apple Development" && echo 1 || echo 0)
|
||||
|
||||
|
||||
Memmon.app: SDK_PATH=$(shell xcrun --show-sdk-path --sdk macosx)
|
||||
Memmon.app: src/*
|
||||
mkdir -p Memmon.app/Contents/MacOS/
|
||||
swiftc $(CFLAGS) src/main.swift \
|
||||
-target arm64-apple-macos10.10 -target x86_64-apple-macos10.10 \
|
||||
-emit-executable -sdk $(SDK_PATH) -o Memmon.app/Contents/MacOS/Memmon
|
||||
mkdir -p Memmon.app/Contents/Resources/
|
||||
cp src/AppIcon.icns Memmon.app/Contents/Resources/AppIcon.icns
|
||||
cp src/Info.plist Memmon.app/Contents/Info.plist
|
||||
echo 'APPL????' > Memmon.app/Contents/PkgInfo
|
||||
@mkdir -p Memmon.app/Contents/MacOS/
|
||||
swiftc ${CFLAGS} src/main.swift -target x86_64-apple-macos10.10 \
|
||||
-emit-executable -sdk ${SDK_PATH} -o bin_x64
|
||||
swiftc ${CFLAGS} src/main.swift -target arm64-apple-macos10.10 \
|
||||
-emit-executable -sdk ${SDK_PATH} -o bin_arm64
|
||||
lipo -create bin_x64 bin_arm64 -o Memmon.app/Contents/MacOS/Memmon
|
||||
@rm bin_x64 bin_arm64
|
||||
@echo 'APPL????' > Memmon.app/Contents/PkgInfo
|
||||
@mkdir -p Memmon.app/Contents/Resources/
|
||||
@cp src/AppIcon.icns Memmon.app/Contents/Resources/AppIcon.icns
|
||||
@cp src/Info.plist Memmon.app/Contents/Info.plist
|
||||
@touch Memmon.app
|
||||
@echo
|
||||
ifeq ($(HAS_SIGN_IDENTITY),1)
|
||||
codesign -v -s 'Apple Development' --options=runtime --timestamp Memmon.app
|
||||
else
|
||||
codesign -v -s - Memmon.app
|
||||
endif
|
||||
@echo
|
||||
@echo 'Verify Signature...'
|
||||
@echo
|
||||
codesign -dvv Memmon.app
|
||||
@echo
|
||||
codesign -vvv --deep --strict Memmon.app
|
||||
ifeq ($(HAS_SIGN_IDENTITY),1)
|
||||
@echo
|
||||
-spctl -vvv --assess --type exec Memmon.app
|
||||
endif
|
||||
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf Memmon.app bin_x64 bin_arm64
|
||||
|
||||
|
||||
.PHONY: release
|
||||
release: VERSION=$(shell grep -A1 CFBundleShortVersionString src/Info.plist | tail -1 | tr -d '[a-z \t</>]')
|
||||
release: VERSION=$(call PLIST,CFBundleShortVersionString)
|
||||
release: Memmon.app
|
||||
tar -czf "Memmon_v$(VERSION).tar.gz" Memmon.app
|
||||
tar -czf "Memmon_v${VERSION}.tar.gz" Memmon.app
|
||||
|
||||
98
README.md
98
README.md
@@ -1,49 +1,89 @@
|
||||
[](#install)
|
||||
[](https://github.com/relikd/Memmon/releases)
|
||||
[](https://github.com/relikd/Memmon/releases)
|
||||
|
||||
<img src="img/icon.svg" width="180" height="180" style="margin: 0 10px; float: right;">
|
||||
<img src="img/icon.svg" width="180" height="180">
|
||||
|
||||
# Memmon
|
||||
|
||||
Memmon remembers what your Mac forgets – A simple deamon that restores your window positions on external monitors.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Why‽
|
||||
|
||||
I am frustrated! Why does my Mac forget all window positions which I moved to a second screen? Every time I unplug the monitor. Every time I close my Macbook lid. Every time I lock my Mac.
|
||||
|
||||
Is it macOS 11? Is it the USB-C-to-HDMI converter dongle (notably one made by Apple)? Why do I have to fix things that Apple should have fixed long ago? …
|
||||
|
||||
|
||||
### Aren't there other solutions?
|
||||
|
||||
Yes, for example [Mjolnir](https://github.com/mjolnirapp/mjolnir) or [Hammerspoon](https://github.com/Hammerspoon/hammerspoon) (and some comercial ones). But I do not need a full-fledged window manager. Nor the dependencies they rely on. I just need to fix this damn bug.
|
||||
|
||||
|
||||
### What is it good for?
|
||||
|
||||
First off, Memmon is just 130 lines of code – no dependencies. You can audit it in 5 minutes. Or just build it from scratch if you like (just run `make`).
|
||||
|
||||
Secondly, it does one thing and one thing only: Save and restore window positions whenever your monitor setup changes.
|
||||
**Limitations:**
|
||||
- Currently, Memmon restores windows in other spaces only if the space is activated.
|
||||
If you know a way to access the accessibility settings of a different space, let me know.
|
||||
- Support for the Misson Control config option “Displays have separate Spaces” is not tested.
|
||||
I will add support for this as soon as I have access to an external monitor again (issue [#5](https://github.com/relikd/Memmon/issues/5#issuecomment-1040611494)).
|
||||
|
||||
|
||||
## Install
|
||||
|
||||
1. You will need macOS 10.10 or newer.
|
||||
2. Grant Memmon the Accessibility privilege. Go to "System Preference" > "Security & Privacy" > "Accessibility" and add Memmon to that list. Otherwise, you can't move other application windows around and the app has no purpose.
|
||||
3. Thats it. The app runs in your status bar.
|
||||
Download and unzip the tar.gz from [latest release](https://github.com/relikd/Memmon/releases/latest).
|
||||
2. Grant Memmon the Accessibility privilege.
|
||||
Go to "System Preference" > "Security & Privacy" > "Accessibility" and add Memmon to that list.
|
||||
(Otherwise, the app has no purpose as it can't move application windows around.)
|
||||
3. Thats it. The app runs in your menu bar.
|
||||
|
||||
Alternatively, you can compile Memmon from source by running `make`, or call the script directly (`swift src/main.swift`) without building an app bundle.
|
||||
|
||||
|
||||
### Hide Status Icon
|
||||
### Menu Bar Icon
|
||||
|
||||
You can hide the status icon either via the same-titled menu entry. If you do so, the only way to quit the app is by killing the process (with Activity.app or `killall Memmon`).
|
||||
You can hide the menu bar icon either via `defaults` or the same-titled menu entry.
|
||||
If you do so, the only way to quit the app is by killing the process (with Activity.app or `killall Memmon`).
|
||||
The menu bar icon stays hidden during this execution only. If you restart the OS or app it will reappear (unless you hide the icon with `defaults`).
|
||||
|
||||
If you like to hide the icon directly on launch, use this app-setting:
|
||||
Memmon has exactly one app-setting, the menu bar icon.
|
||||
You can manipulate the display of the icon, or hide the icon completely:
|
||||
|
||||
```sh
|
||||
# disable status icon completely
|
||||
defaults write de.relikd.Memmon invisible -bool True
|
||||
# re-enable status icon
|
||||
defaults delete de.relikd.Memmon invisible
|
||||
# disable menu bar icon completely
|
||||
defaults write de.relikd.Memmon icon -int 0
|
||||
# Use window-dots-icon
|
||||
defaults write de.relikd.Memmon icon -int 1
|
||||
# Use monitor-with-windows icon (default)
|
||||
defaults write de.relikd.Memmon icon -int 2
|
||||
# re-enable menu bar icon and use default icon
|
||||
defaults delete de.relikd.Memmon icon
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
## FAQ
|
||||
|
||||
### Why‽
|
||||
|
||||
I am frustrated!
|
||||
Why does my Mac forget all window positions which I moved to a second screen?
|
||||
Every time I unplug the monitor.
|
||||
Every time I close my Macbook lid.
|
||||
Every time I lock my Mac.
|
||||
|
||||
Is it macOS 11?
|
||||
Is it the USB-C-to-HDMI converter dongle (notably one made by Apple)?
|
||||
Why do I have to fix things that Apple should have fixed long ago? …
|
||||
|
||||
|
||||
### Aren't there other solutions?
|
||||
|
||||
Yes, for example, you can use [Mjolnir](https://github.com/mjolnirapp/mjolnir) or [Hammerspoon](https://github.com/Hammerspoon/hammerspoon) (and some comercial ones) to restore your perfect window setup on a button press.
|
||||
But I do not need a full-fledged window manager or the dependencies it relies on.
|
||||
Nor do I want to constantly adjust for new windows.
|
||||
Actually, I don't want to think about this problem at all – I just want to fix this damn bug.
|
||||
|
||||
|
||||
### What is it good for?
|
||||
|
||||
First off, Memmon is less than 300 lines of code – no dependencies.
|
||||
You can audit it in 10 minutes...
|
||||
And build it from scratch – just run `make`.
|
||||
|
||||
Secondly, it does one thing and one thing only:
|
||||
Save and restore window positions whenever your monitor setup changes.
|
||||
|
||||
|
||||
### Develop
|
||||
|
||||
You can either run the `main.swift` file directly with `swift main.swift`, via Terminal `./main.swift` (`chmod 755 main.swift`), or create a new Xcode project.
|
||||
In Xcode, select the Command-Line template and replace the template provided `main.swift` with this one.
|
||||
|
||||
BIN
img/status_icons.png
Normal file
BIN
img/status_icons.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/AppIcon.icns
BIN
src/AppIcon.icns
Binary file not shown.
@@ -15,7 +15,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>1.5</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>42</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
|
||||
295
src/main.swift
295
src/main.swift
@@ -2,25 +2,231 @@
|
||||
import Cocoa
|
||||
import AppKit
|
||||
|
||||
typealias AppPID = Int32 // see kCGWindowOwnerPID
|
||||
typealias WinNum = Int // see kCGWindowNumber (Int32) and NSWindow.windowNumber (Int)
|
||||
typealias WinPos = (WinNum, CGRect) // win-num, bounds
|
||||
typealias WinConf = [AppPID: [WinPos]] // app-pid, window-list
|
||||
typealias SpaceId = WinNum // see NSWindow.windowNumber (Int)
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var statusItem: NSStatusItem!
|
||||
private var numScreens: Int = NSScreen.screens.count
|
||||
private var state: [Int: [Int32: [CGRect]]] = [:] // [screencount: [pid: [windows]]]
|
||||
private var state: [Int: WinConf] = [:] // [screencount: [pid: [windows]]]
|
||||
|
||||
private var spacesAll: [SpaceId] = [] // keep forever (and keep order)
|
||||
private var spacesVisited: Set<WinNum> = [] // fill-up on space-switch
|
||||
private var spacesNeedRestore: Set<SpaceId> = [] // dropped after restore
|
||||
|
||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||
if UserDefaults.standard.bool(forKey: "invisible") == true {
|
||||
return
|
||||
}
|
||||
// show Accessibility Permissions popup
|
||||
AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeUnretainedValue() : true] as CFDictionary)
|
||||
// track space changes
|
||||
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(self.activeSpaceChanged), name: NSWorkspace.activeSpaceDidChangeNotification, object: nil)
|
||||
_ = self.currentSpace() // create space-id win for current space
|
||||
self.spacesVisited = Set(self.getWinIds())
|
||||
// create status menu icon
|
||||
UserDefaults.standard.register(defaults: ["icon": 2])
|
||||
let icon = UserDefaults.standard.integer(forKey: "icon")
|
||||
if icon == 0 { return }
|
||||
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
if let button = self.statusItem.button {
|
||||
button.image = self.statusMenuIcon()
|
||||
switch icon {
|
||||
case 1: button.image = NSImage.statusIconDots
|
||||
case 2: button.image = NSImage.statusIconMonitor
|
||||
default: button.image = NSImage.statusIconMonitor
|
||||
}
|
||||
}
|
||||
self.statusItem.menu = NSMenu(title: "")
|
||||
self.statusItem.menu!.addItem(withTitle: "Memmon (v1.5)", action: nil, keyEquivalent: "")
|
||||
self.statusItem.menu!.addItem(withTitle: "Hide Status Icon", action: #selector(self.enableInvisbleMode), keyEquivalent: "")
|
||||
self.statusItem.menu!.addItem(withTitle: "Quit", action: #selector(NSApp.terminate), keyEquivalent: "q")
|
||||
}
|
||||
|
||||
func statusMenuIcon() -> NSImage {
|
||||
@objc func enableInvisbleMode() {
|
||||
self.statusItem = nil
|
||||
}
|
||||
|
||||
func applicationDidChangeScreenParameters(_ notification: Notification) {
|
||||
if self.numScreens != NSScreen.screens.count {
|
||||
self.saveState()
|
||||
self.numScreens = NSScreen.screens.count
|
||||
self.spacesVisited.removeAll(keepingCapacity: true)
|
||||
self.restoreState()
|
||||
}
|
||||
}
|
||||
|
||||
private func getWinIds(allSpaces: Bool = false) -> [WinNum] {
|
||||
NSWindow.windowNumbers(options: allSpaces ? [.allApplications, .allSpaces] : .allApplications)?.map{ $0.intValue } ?? []
|
||||
}
|
||||
|
||||
// MARK: - Save State (CGWindow) -
|
||||
|
||||
private func saveState() {
|
||||
self.spacesNeedRestore = Set(self.spacesAll)
|
||||
if self.state[self.numScreens] == nil {
|
||||
self.state[self.numScreens] = [:] // otherwise state.keys wont run
|
||||
}
|
||||
let newState = self.getState()
|
||||
let dummy: WinPos = (0, CGRect.zero)
|
||||
for kNum in self.state.keys {
|
||||
let isCurrent = kNum == self.numScreens
|
||||
var tmp_state: WinConf = [:]
|
||||
for (n_app, n_windows) in newState {
|
||||
if let old_windows = self.state[kNum]![n_app] {
|
||||
var win_arr: [WinPos] = []
|
||||
for n_win in n_windows {
|
||||
// In theory, every space that was visited, was also restored.
|
||||
// If not visited (and not restored) then windows may still appear minimized,
|
||||
// so we rather copy the old value, assuming windows weren't moved while in an unvisited space.
|
||||
if isCurrent && self.spacesVisited.contains(n_win.0) {
|
||||
win_arr.append(n_win)
|
||||
} else {
|
||||
// caution! the positions of all other states are updated as well.
|
||||
let old_win = old_windows.first { $0.0 == n_win.0 }
|
||||
win_arr.append(old_win ?? dummy)
|
||||
}
|
||||
}
|
||||
tmp_state[n_app] = win_arr
|
||||
} else if isCurrent { // and not saved yet
|
||||
tmp_state[n_app] = n_windows // TODO: or only add if visited?
|
||||
}
|
||||
}
|
||||
self.state[kNum] = tmp_state
|
||||
}
|
||||
}
|
||||
|
||||
private func getState() -> WinConf {
|
||||
let allWinNums = self.getWinIds(allSpaces: true).filter { !self.spacesAll.contains($0) }
|
||||
var state: WinConf = [:]
|
||||
let windowList = CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID) as NSArray? as? [[String: AnyObject]]
|
||||
|
||||
for entry in windowList! {
|
||||
// let owner = entry[kCGWindowOwnerName as String] as! String
|
||||
if entry[kCGWindowLayer as String] as! CGWindowLevel != kCGNormalWindowLevel {
|
||||
continue
|
||||
}
|
||||
let winNum = entry[kCGWindowNumber as String] as! WinNum
|
||||
guard let insIdx = allWinNums.firstIndex(of: winNum) else {
|
||||
continue
|
||||
}
|
||||
let pid = entry[kCGWindowOwnerPID as String] as! AppPID
|
||||
let b = entry[kCGWindowBounds as String] as! [String: Int]
|
||||
let bounds = CGRect(x: b["X"]!, y: b["Y"]!, width: b["Width"]!, height: b["Height"]!)
|
||||
if (state[pid] == nil) {
|
||||
state[pid] = [(winNum, bounds)]
|
||||
} else {
|
||||
// allWinNums is sorted by recent activity, windowList is not. Keep order while appending.
|
||||
if let idx = state[pid]!.firstIndex(where: { insIdx < allWinNums.firstIndex(of: $0.0)! }) {
|
||||
state[pid]!.insert((winNum, bounds), at: idx)
|
||||
} else {
|
||||
state[pid]!.append((winNum, bounds))
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// MARK: - Restore State (AXUIElement) -
|
||||
|
||||
private func restoreState() {
|
||||
if let space = currentSpace(), self.spacesNeedRestore.contains(space) {
|
||||
self.spacesNeedRestore.remove(space)
|
||||
let spaceWinNums = self.getWinIds()
|
||||
self.spacesVisited.formUnion(spaceWinNums)
|
||||
for (pid, bounds) in self.state[self.numScreens] ?? [:] {
|
||||
self.setWindowSizes(pid, bounds.filter{ spaceWinNums.contains($0.0) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setWindowSizes(_ pid: pid_t, _ sizes: [WinPos]) {
|
||||
guard sizes.count > 0 else { return }
|
||||
let win = self.axWinList(pid)
|
||||
guard win.count == sizes.count else { return }
|
||||
for i in 0 ..< win.count {
|
||||
var pt = sizes[i].1
|
||||
if pt.isEmpty { continue } // filter dummy elements
|
||||
let origin = AXValueCreate(AXValueType(rawValue: kAXValueCGPointType)!, &pt.origin)!
|
||||
let size = AXValueCreate(AXValueType(rawValue: kAXValueCGSizeType)!, &pt.size)!
|
||||
AXUIElementSetAttributeValue(win[i], kAXPositionAttribute as CFString, origin);
|
||||
AXUIElementSetAttributeValue(win[i], kAXSizeAttribute as CFString, size);
|
||||
}
|
||||
}
|
||||
|
||||
private func axWinList(_ pid: pid_t) -> [AXUIElement] {
|
||||
let appRef = AXUIElementCreateApplication(pid)
|
||||
var value: CFTypeRef?
|
||||
AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &value)
|
||||
if let windowList = value as? [AXUIElement] {
|
||||
var tmp: [AXUIElement] = []
|
||||
for win in windowList {
|
||||
var role: CFTypeRef?
|
||||
AXUIElementCopyAttributeValue(win, kAXRoleAttribute as CFString, &role)
|
||||
if role as? String == kAXWindowRole {
|
||||
tmp.append(win) // filter e.g. Finder's AXScrollArea
|
||||
}
|
||||
}
|
||||
return tmp
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// MARK: - Space Management -
|
||||
|
||||
@objc func activeSpaceChanged(_ notification: Notification) {
|
||||
self.restoreState()
|
||||
}
|
||||
|
||||
private func currentSpace() -> SpaceId? {
|
||||
let thisSpace = self.getWinIds()
|
||||
var candidates = self.spacesAll.filter { thisSpace.contains($0) }
|
||||
if candidates.count > 0 {
|
||||
let best = candidates.removeFirst()
|
||||
if candidates.count > 0 {
|
||||
// if a full-screen app is closed, win moves to current active space -> remove duplicates
|
||||
self.spacesAll.removeAll { candidates.contains($0) }
|
||||
for oldNum in candidates {
|
||||
NSApp.window(withWindowNumber: oldNum)?.close()
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
// create new space-id window (space was not visited yet)
|
||||
let win = NSWindow(contentRect: .zero, styleMask: .borderless, backing: .buffered, defer: false)
|
||||
win.isReleasedWhenClosed = false // win is released either way. But crashes if true.
|
||||
guard win.isOnActiveSpace else {
|
||||
// dashboard or other full-screen app that prohibits display
|
||||
return nil
|
||||
}
|
||||
win.collectionBehavior = [.ignoresCycle, .stationary]
|
||||
win.setIsVisible(true)
|
||||
self.spacesAll.append(win.windowNumber)
|
||||
return win.windowNumber
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status Bar Icon -
|
||||
|
||||
extension NSImage {
|
||||
static var statusIconDots: NSImage {
|
||||
let img = NSImage.init(size: .init(width: 20, height: 20), flipped: true) {
|
||||
let ctx = NSGraphicsContext.current!.cgContext
|
||||
let w = $0.width
|
||||
let h = $0.height
|
||||
let sw = 0.025 * w // stroke width
|
||||
ctx.stroke(CGRect(x: 0.0 * w, y: 0.15 * h, width: 1.0 * w, height: 0.7 * h).insetBy(dx: sw / 2, dy: sw / 2), width: sw)
|
||||
ctx.fill(CGRect(x: 0, y: 0.55 * h, width: w, height: sw))
|
||||
let circle = CGRect(x: 0, y: 0.25 * h, width: 0.2 * w, height: 0.2 * w)
|
||||
ctx.fillEllipse(in: circle.offsetBy(dx: 0.12 * w, dy: 0))
|
||||
ctx.fillEllipse(in: circle.offsetBy(dx: 0.4 * w, dy: 0))
|
||||
ctx.fillEllipse(in: circle.offsetBy(dx: 0.68 * w, dy: 0))
|
||||
return true
|
||||
}
|
||||
img.isTemplate = true
|
||||
return img
|
||||
}
|
||||
|
||||
static var statusIconMonitor: NSImage {
|
||||
let img = NSImage.init(size: .init(width: 21, height: 14), flipped: true) {
|
||||
let ctx = NSGraphicsContext.current!.cgContext
|
||||
let w = $0.width
|
||||
@@ -41,84 +247,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
img.isTemplate = true
|
||||
return img
|
||||
}
|
||||
|
||||
@objc func enableInvisbleMode() {
|
||||
self.statusItem = nil
|
||||
}
|
||||
|
||||
func applicationDidChangeScreenParameters(_ notification: Notification) {
|
||||
if numScreens != NSScreen.screens.count {
|
||||
// save state
|
||||
self.state[numScreens] = self.getState()
|
||||
numScreens = NSScreen.screens.count
|
||||
// restore state
|
||||
if let previous = self.state[numScreens] {
|
||||
self.restoreState(previous)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getState() -> [Int32: [CGRect]] {
|
||||
var state: [Int32: [CGRect]] = [:]
|
||||
let windowList = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as NSArray? as? [[String: AnyObject]]
|
||||
for entry in windowList! {
|
||||
let pid = entry[kCGWindowOwnerPID as String] as! Int32
|
||||
let layer = entry[kCGWindowLayer as String] as! Int32
|
||||
// let owner = entry[kCGWindowOwnerName as String] as! String
|
||||
if layer != 0 {
|
||||
continue
|
||||
}
|
||||
let b = entry[kCGWindowBounds as String] as! [String: Int]
|
||||
let bounds = CGRect(x: b["X"]!, y: b["Y"]!, width: b["Width"]!, height: b["Height"]!)
|
||||
if (state[pid] == nil) {
|
||||
state[pid] = [bounds]
|
||||
} else {
|
||||
state[pid]!.append(bounds)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private func restoreState(_ state: [Int32: [CGRect]]) {
|
||||
for (pid, bounds) in state {
|
||||
self.setWindowSizes(pid, bounds)
|
||||
}
|
||||
}
|
||||
|
||||
private func setWindowSizes(_ pid: Int32, _ sizes: [CGRect]) {
|
||||
let win = self.axWinList(pid)
|
||||
guard win.count > 0, win.count == sizes.count else {
|
||||
print(pid, win.count, sizes.count)
|
||||
return
|
||||
}
|
||||
for i in 0 ..< win.count {
|
||||
var newPoint = sizes[i].origin
|
||||
var newSize = sizes[i].size
|
||||
AXUIElementSetAttributeValue(win[i], kAXPositionAttribute as CFString,
|
||||
AXValueCreate(AXValueType(rawValue: kAXValueCGPointType)!, &newPoint)!);
|
||||
AXUIElementSetAttributeValue(win[i], kAXSizeAttribute as CFString,
|
||||
AXValueCreate(AXValueType(rawValue: kAXValueCGSizeType)!, &newSize)!);
|
||||
}
|
||||
}
|
||||
|
||||
private func axWinList(_ pid: Int32) -> [AXUIElement] {
|
||||
let appRef = AXUIElementCreateApplication(pid)
|
||||
var value: AnyObject?
|
||||
AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &value)
|
||||
if let windowList = value as? [AXUIElement] {
|
||||
var tmp: [AXUIElement] = []
|
||||
for win in windowList {
|
||||
var role: CFTypeRef?
|
||||
AXUIElementCopyAttributeValue(win, kAXRoleAttribute as CFString, &role)
|
||||
if role as? String == kAXWindowRole {
|
||||
tmp.append(win) // filter e.g. Finder's AXScrollArea
|
||||
}
|
||||
}
|
||||
return tmp
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
// MARK: - Main Entry
|
||||
|
||||
let delegate = AppDelegate()
|
||||
NSApplication.shared.delegate = delegate
|
||||
|
||||
Reference in New Issue
Block a user