3 Commits
v1.2 ... v1.5

Author SHA1 Message Date
relikd
60d2a15f0f Fix universal build (issue #2) 2021-11-24 18:26:49 +01:00
relikd
d26169f8b9 restoring spaces v2 2021-10-27 18:48:16 +02:00
relikd
9920201fe2 preliminary support for spaces 2021-10-27 18:24:00 +02:00
5 changed files with 98 additions and 34 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.DS_Store
/*.app
/*.tar.gz
*.xcodeproj

View File

@@ -12,9 +12,12 @@ PLIST=$(shell grep -A1 $(1) src/Info.plist | tail -1 | cut -d'>' -f2 | cut -d'<'
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
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

View File

@@ -1,5 +1,6 @@
[![macOS 10.10+](https://img.shields.io/badge/macOS-10.10+-888)](#install)
[![Current release](https://img.shields.io/github/release/relikd/Memmon)](https://github.com/relikd/Memmon/releases)
[![All downloads](https://img.shields.io/github/downloads/relikd/Memmon/total)](https://github.com/relikd/Memmon/releases)
<img src="img/icon.svg" width="180" height="180">
@@ -7,7 +8,7 @@
Memmon remembers what your Mac forgets A simple deamon that restores your window positions on external monitors.
**Limitations:** Currently, Memmon can not restore windows in other spaces, only the currently active space. If you know a way to access the accessibility settings of a different space, let me know.
**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
@@ -19,7 +20,7 @@ Memmon remembers what your Mac forgets A simple deamon that restores your wi
### Status Icon
You can hide the status 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`).
You can hide the status 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 status icon stays hidden during this execution only. If you restart the OS or app it will reappear (unless you hide the icon with `defaults`).
Memmon has exactly one app-setting, the status icon. You can manipulate the display of the icon, or hide the icon completely:
@@ -53,7 +54,7 @@ Yes, for example [Mjolnir](https://github.com/mjolnirapp/mjolnir) or [Hammerspoo
### 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.

View File

@@ -15,7 +15,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.2</string>
<string>1.5</string>
<key>CFBundleVersion</key>
<string>42</string>
<key>LSMinimumSystemVersion</key>

View File

@@ -3,18 +3,27 @@ import Cocoa
import AppKit
typealias AppPID = Int32 // see kCGWindowOwnerPID
typealias WinNum = Int32 // see kCGWindowNumber
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: 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) {
// 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")
@@ -28,7 +37,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
self.statusItem.menu = NSMenu(title: "")
self.statusItem.menu!.addItem(withTitle: "Memmon (v1.2)", action: nil, keyEquivalent: "")
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")
}
@@ -38,42 +47,56 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
func applicationDidChangeScreenParameters(_ notification: Notification) {
if numScreens != NSScreen.screens.count {
if self.numScreens != NSScreen.screens.count {
self.saveState()
numScreens = NSScreen.screens.count
self.numScreens = NSScreen.screens.count
self.spacesVisited.removeAll(keepingCapacity: true)
self.restoreState()
}
}
private func getWinIds(allSpaces: Bool) -> [WinNum] {
NSWindow.windowNumbers(options: allSpaces ? [.allApplications, .allSpaces] : .allApplications)?.map{ $0.int32Value } ?? []
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()
self.state[numScreens] = newState
// update existing
let dummy: WinPos = (0, CGRect.zero)
for kNum in self.state.keys {
if kNum == numScreens { continue } // current state, already set above
let isCurrent = kNum == self.numScreens
var tmp_state: WinConf = [:]
for (n_app, new_val) in newState {
if let old_val = self.state[kNum]![n_app] {
tmp_state[n_app] = []
for (n_win, _) in new_val {
let old_pos = old_val.first { $0.0 == n_win }
tmp_state[n_app]!.append(old_pos ?? dummy)
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)
let allWinNums = self.getWinIds(allSpaces: true).filter { !self.spacesAll.contains($0) }
var state: WinConf = [:]
let windowList = CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID) as NSArray? as? [[String: AnyObject]]
@@ -106,24 +129,27 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Restore State (AXUIElement) -
private func restoreState() {
for (pid, bounds) in self.state[numScreens] ?? [:] {
let spaceWinNums = getWinIds(allSpaces: false)
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 > 0, win.count == sizes.count else {
return
}
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
AXUIElementSetAttributeValue(win[i], kAXPositionAttribute as CFString,
AXValueCreate(AXValueType(rawValue: kAXValueCGPointType)!, &pt.origin)!);
AXUIElementSetAttributeValue(win[i], kAXSizeAttribute as CFString,
AXValueCreate(AXValueType(rawValue: kAXValueCGSizeType)!, &pt.size)!);
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);
}
}
@@ -144,6 +170,39 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
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 -