Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d26169f8b9 | ||
|
|
9920201fe2 | ||
|
|
acc0b03522 | ||
|
|
7510327139 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
/*.app
|
/*.app
|
||||||
/*.tar.gz
|
/*.tar.gz
|
||||||
|
*.xcodeproj
|
||||||
|
|||||||
33
Makefile
33
Makefile
@@ -6,21 +6,32 @@ else
|
|||||||
CFLAGS=-O
|
CFLAGS=-O
|
||||||
endif
|
endif
|
||||||
|
|
||||||
APP_DIR = Memmon.app/Contents
|
PLIST=$(shell grep -A1 $(1) src/Info.plist | tail -1 | cut -d'>' -f2 | cut -d'<' -f1)
|
||||||
|
|
||||||
Memmon.app: SDK_PATH = $(shell xcrun --show-sdk-path --sdk macosx)
|
|
||||||
|
Memmon.app: SDK_PATH=$(shell xcrun --show-sdk-path --sdk macosx)
|
||||||
Memmon.app: src/*
|
Memmon.app: src/*
|
||||||
mkdir -p Memmon.app/Contents/MacOS/
|
@mkdir -p Memmon.app/Contents/MacOS/
|
||||||
swiftc $(CFLAGS) src/main.swift \
|
swiftc ${CFLAGS} src/main.swift \
|
||||||
-target arm64-apple-macos10.10 -target x86_64-apple-macos10.10 \
|
-target arm64-apple-macos10.10 -target x86_64-apple-macos10.10 \
|
||||||
-emit-executable -sdk $(SDK_PATH) -o Memmon.app/Contents/MacOS/Memmon
|
-emit-executable -sdk ${SDK_PATH} -o Memmon.app/Contents/MacOS/Memmon
|
||||||
mkdir -p Memmon.app/Contents/Resources/
|
@echo 'APPL????' > Memmon.app/Contents/PkgInfo
|
||||||
cp src/AppIcon.icns Memmon.app/Contents/Resources/AppIcon.icns
|
@mkdir -p Memmon.app/Contents/Resources/
|
||||||
cp src/Info.plist Memmon.app/Contents/Info.plist
|
@cp src/AppIcon.icns Memmon.app/Contents/Resources/AppIcon.icns
|
||||||
echo 'APPL????' > Memmon.app/Contents/PkgInfo
|
@cp src/Info.plist Memmon.app/Contents/Info.plist
|
||||||
@touch Memmon.app
|
@touch Memmon.app
|
||||||
|
@echo
|
||||||
|
codesign -v -s 'Apple Development' --options=runtime --timestamp Memmon.app
|
||||||
|
@echo
|
||||||
|
@echo 'Verify Signature...'
|
||||||
|
@echo
|
||||||
|
codesign -dvv Memmon.app
|
||||||
|
@echo
|
||||||
|
codesign -vvv --deep --strict Memmon.app
|
||||||
|
@echo
|
||||||
|
spctl -vvv --assess --type exec Memmon.app
|
||||||
|
|
||||||
.PHONY: release
|
.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
|
release: Memmon.app
|
||||||
tar -czf "Memmon_v$(VERSION).tar.gz" Memmon.app
|
tar -czf "Memmon_v${VERSION}.tar.gz" Memmon.app
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
[](#install)
|
[](#install)
|
||||||
[](https://github.com/relikd/Memmon/releases)
|
[](https://github.com/relikd/Memmon/releases)
|
||||||
|
[](https://github.com/relikd/Memmon/releases)
|
||||||
|
|
||||||
<img src="img/icon.svg" width="180" height="180">
|
<img src="img/icon.svg" width="180" height="180">
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@
|
|||||||
|
|
||||||
Memmon remembers what your Mac forgets – A simple deamon that restores your window positions on external monitors.
|
Memmon remembers what your Mac forgets – A simple deamon that restores your window positions on external monitors.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -51,7 +54,7 @@ Yes, for example [Mjolnir](https://github.com/mjolnirapp/mjolnir) or [Hammerspoo
|
|||||||
|
|
||||||
### What is it good for?
|
### What is it good for?
|
||||||
|
|
||||||
First off, Memmon is just 140 lines of code – no dependencies. You can audit it in 5 minutes and build it from scratch – just run `make`.
|
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.
|
Secondly, it does one thing and one thing only: Save and restore window positions whenever your monitor setup changes.
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.1</string>
|
<string>1.4</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>42</string>
|
<string>42</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
|||||||
160
src/main.swift
160
src/main.swift
@@ -2,15 +2,29 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
typealias WinPos = (Int32, CGRect) // win-num, bounds
|
typealias AppPID = Int32 // see kCGWindowOwnerPID
|
||||||
typealias WinConf = [Int32: [WinPos]] // app-pid, window-list
|
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 {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
private var statusItem: NSStatusItem!
|
private var statusItem: NSStatusItem!
|
||||||
private var numScreens: Int = NSScreen.screens.count
|
private var numScreens: Int = NSScreen.screens.count
|
||||||
private var state: [Int: WinConf] = [:] // [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) {
|
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||||
|
// 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])
|
UserDefaults.standard.register(defaults: ["icon": 2])
|
||||||
let icon = UserDefaults.standard.integer(forKey: "icon")
|
let icon = UserDefaults.standard.integer(forKey: "icon")
|
||||||
if icon == 0 { return }
|
if icon == 0 { return }
|
||||||
@@ -23,7 +37,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.statusItem.menu = NSMenu(title: "")
|
self.statusItem.menu = NSMenu(title: "")
|
||||||
self.statusItem.menu!.addItem(withTitle: "Memmon (v1.1)", action: nil, keyEquivalent: "")
|
self.statusItem.menu!.addItem(withTitle: "Memmon (v1.4)", action: nil, keyEquivalent: "")
|
||||||
self.statusItem.menu!.addItem(withTitle: "Hide Status Icon", action: #selector(self.enableInvisbleMode), 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")
|
self.statusItem.menu!.addItem(withTitle: "Quit", action: #selector(NSApp.terminate), keyEquivalent: "q")
|
||||||
}
|
}
|
||||||
@@ -33,47 +47,56 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applicationDidChangeScreenParameters(_ notification: Notification) {
|
func applicationDidChangeScreenParameters(_ notification: Notification) {
|
||||||
if numScreens != NSScreen.screens.count {
|
if self.numScreens != NSScreen.screens.count {
|
||||||
self.saveState()
|
self.saveState()
|
||||||
numScreens = NSScreen.screens.count
|
self.numScreens = NSScreen.screens.count
|
||||||
if let previous = self.state[numScreens] {
|
self.spacesVisited.removeAll(keepingCapacity: true)
|
||||||
self.restoreState(previous)
|
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() {
|
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 newState = self.getState()
|
||||||
self.state[numScreens] = newState
|
|
||||||
// update existing
|
|
||||||
let dummy: WinPos = (0, CGRect.zero)
|
let dummy: WinPos = (0, CGRect.zero)
|
||||||
for kNum in self.state.keys {
|
for kNum in self.state.keys {
|
||||||
if kNum == numScreens { continue } // current state, already set above
|
let isCurrent = kNum == self.numScreens
|
||||||
var tmp_state: WinConf = [:]
|
var tmp_state: WinConf = [:]
|
||||||
for (n_app, new_val) in newState {
|
for (n_app, n_windows) in newState {
|
||||||
if let old_val = self.state[kNum]![n_app] {
|
if let old_windows = self.state[kNum]![n_app] {
|
||||||
tmp_state[n_app] = []
|
var win_arr: [WinPos] = []
|
||||||
for (n_win, _) in new_val {
|
for n_win in n_windows {
|
||||||
let old_pos = old_val.first { $0.0 == n_win }
|
// In theory, every space that was visited, was also restored.
|
||||||
tmp_state[n_app]!.append(old_pos ?? dummy)
|
// 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
|
self.state[kNum] = tmp_state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restoreState(_ state: WinConf) {
|
|
||||||
for (pid, bounds) in state {
|
|
||||||
self.setWindowSizes(pid, bounds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getState() -> WinConf {
|
private func getState() -> WinConf {
|
||||||
var allWinNums: [Int32] = []
|
let allWinNums = self.getWinIds(allSpaces: true).filter { !self.spacesAll.contains($0) }
|
||||||
for winNum in NSWindow.windowNumbers(options: [.allApplications, .allSpaces]) ?? [] {
|
|
||||||
allWinNums.append(winNum.int32Value)
|
|
||||||
}
|
|
||||||
var state: WinConf = [:]
|
var state: WinConf = [:]
|
||||||
let windowList = CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID) as NSArray? as? [[String: AnyObject]]
|
let windowList = CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID) as NSArray? as? [[String: AnyObject]]
|
||||||
|
|
||||||
@@ -82,11 +105,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
if entry[kCGWindowLayer as String] as! CGWindowLevel != kCGNormalWindowLevel {
|
if entry[kCGWindowLayer as String] as! CGWindowLevel != kCGNormalWindowLevel {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let winNum = entry[kCGWindowNumber as String] as! Int32
|
let winNum = entry[kCGWindowNumber as String] as! WinNum
|
||||||
guard let insIdx = allWinNums.firstIndex(of: winNum) else {
|
guard let insIdx = allWinNums.firstIndex(of: winNum) else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let pid = entry[kCGWindowOwnerPID as String] as! Int32
|
let pid = entry[kCGWindowOwnerPID as String] as! AppPID
|
||||||
let b = entry[kCGWindowBounds as String] as! [String: Int]
|
let b = entry[kCGWindowBounds as String] as! [String: Int]
|
||||||
let bounds = CGRect(x: b["X"]!, y: b["Y"]!, width: b["Width"]!, height: b["Height"]!)
|
let bounds = CGRect(x: b["X"]!, y: b["Y"]!, width: b["Width"]!, height: b["Height"]!)
|
||||||
if (state[pid] == nil) {
|
if (state[pid] == nil) {
|
||||||
@@ -103,24 +126,36 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setWindowSizes(_ pid: Int32, _ sizes: [WinPos]) {
|
// MARK: - Restore State (AXUIElement) -
|
||||||
let win = self.axWinList(pid)
|
|
||||||
guard win.count > 0, win.count == sizes.count else {
|
private func restoreState() {
|
||||||
return
|
if let space = currentSpace(), self.spacesNeedRestore.contains(space) {
|
||||||
}
|
self.spacesNeedRestore.remove(space)
|
||||||
for i in 0 ..< win.count {
|
let spaceWinNums = self.getWinIds()
|
||||||
var pt = sizes[i].1
|
self.spacesVisited.formUnion(spaceWinNums)
|
||||||
if pt.isEmpty { continue } // filter dummy elements
|
for (pid, bounds) in self.state[self.numScreens] ?? [:] {
|
||||||
AXUIElementSetAttributeValue(win[i], kAXPositionAttribute as CFString,
|
self.setWindowSizes(pid, bounds.filter{ spaceWinNums.contains($0.0) })
|
||||||
AXValueCreate(AXValueType(rawValue: kAXValueCGPointType)!, &pt.origin)!);
|
}
|
||||||
AXUIElementSetAttributeValue(win[i], kAXSizeAttribute as CFString,
|
|
||||||
AXValueCreate(AXValueType(rawValue: kAXValueCGSizeType)!, &pt.size)!);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func axWinList(_ pid: Int32) -> [AXUIElement] {
|
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)
|
let appRef = AXUIElementCreateApplication(pid)
|
||||||
var value: AnyObject?
|
var value: CFTypeRef?
|
||||||
AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &value)
|
AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &value)
|
||||||
if let windowList = value as? [AXUIElement] {
|
if let windowList = value as? [AXUIElement] {
|
||||||
var tmp: [AXUIElement] = []
|
var tmp: [AXUIElement] = []
|
||||||
@@ -135,8 +170,43 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
return []
|
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 {
|
extension NSImage {
|
||||||
static var statusIconDots: NSImage {
|
static var statusIconDots: NSImage {
|
||||||
let img = NSImage.init(size: .init(width: 20, height: 20), flipped: true) {
|
let img = NSImage.init(size: .init(width: 20, height: 20), flipped: true) {
|
||||||
@@ -179,6 +249,8 @@ extension NSImage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Main Entry
|
||||||
|
|
||||||
let delegate = AppDelegate()
|
let delegate = AppDelegate()
|
||||||
NSApplication.shared.delegate = delegate
|
NSApplication.shared.delegate = delegate
|
||||||
NSApplication.shared.run()
|
NSApplication.shared.run()
|
||||||
|
|||||||
Reference in New Issue
Block a user