6 Commits
v1.0 ... v1.3

Author SHA1 Message Date
relikd
9920201fe2 preliminary support for spaces 2021-10-27 18:24:00 +02:00
relikd
acc0b03522 ask for for accessibility permission + fix restore current space only 2021-10-26 23:57:27 +02:00
relikd
7510327139 Readme: add spaces limitation note 2021-10-22 18:28:39 +02:00
relikd
0371e316fd add alternative dots-icon 2021-10-21 16:21:28 +02:00
relikd
86e33a871d Fix window order restore + hidden windows (issue #1) 2021-10-21 15:34:01 +02:00
relikd
733419c214 add app name to menu + remove icp4, icp5, icp6 from app icon 2021-10-13 01:17:35 +02:00
6 changed files with 264 additions and 116 deletions

View File

@@ -6,21 +6,32 @@ 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)
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/*
mkdir -p Memmon.app/Contents/MacOS/
swiftc $(CFLAGS) src/main.swift \
@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
-emit-executable -sdk ${SDK_PATH} -o Memmon.app/Contents/MacOS/Memmon
@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
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
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

View File

@@ -1,12 +1,42 @@
[![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)
<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.
**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.
## 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.
### 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`).
Memmon has exactly one app-setting, the status icon. You can manipulate the display of the icon, or hide the icon completely:
```sh
# disable status 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 status icon and use default icon
defaults delete de.relikd.Memmon icon
```
![status icons](img/status_icons.png)
## FAQ
### Why‽
@@ -23,27 +53,11 @@ Yes, for example [Mjolnir](https://github.com/mjolnirapp/mjolnir) or [Hammerspoo
### 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`).
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`.
Secondly, it does one thing and one thing only: Save and restore window positions whenever your monitor setup changes.
## Install
### Develop
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.
### Hide Status 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`).
If you like to hide the icon directly on launch, use this app-setting:
```sh
# disable status icon completely
defaults write de.relikd.Memmon invisible -bool True
# re-enable status icon
defaults delete de.relikd.Memmon invisible
```
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. Select the Command-Line template and after creation replace the existing `main.swift` with the bundled one.

BIN
img/status_icons.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

View File

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

View File

@@ -2,25 +2,223 @@
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 time: Date = Date.distantPast
private var spacesAll: [SpaceId] = [] // keep forever (and keep order)
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
// 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.3)", 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 numScreens != NSScreen.screens.count {
self.saveState()
numScreens = NSScreen.screens.count
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)
guard self.time.timeIntervalSinceNow < -5 else {
// Last save is less than 5 sec ago.
// Do not override a (probably) still correct state.
// Otherwise a monitor flicker will forget win-positions in other spaces.
return
}
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
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)
}
}
}
self.state[kNum] = tmp_state
}
self.time = Date(timeIntervalSinceNow: 0)
}
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()
for (pid, bounds) in self.state[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,85 +239,10 @@ 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
NSApplication.shared.run()