Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0371e316fd | ||
|
|
86e33a871d | ||
|
|
733419c214 |
52
README.md
52
README.md
@@ -1,12 +1,40 @@
|
|||||||
[](#install)
|
[](#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
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
### Why‽
|
### Why‽
|
||||||
@@ -23,27 +51,11 @@ 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 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.
|
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.
|
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.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|||||||
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>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>1.1</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>42</string>
|
<string>42</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
|||||||
167
src/main.swift
167
src/main.swift
@@ -2,102 +2,119 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
|
typealias WinPos = (Int32, CGRect) // win-num, bounds
|
||||||
|
typealias WinConf = [Int32: [WinPos]] // app-pid, window-list
|
||||||
|
|
||||||
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: [Int32: [CGRect]]] = [:] // [screencount: [pid: [windows]]]
|
private var state: [Int: WinConf] = [:] // [screencount: [pid: [windows]]]
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||||
if UserDefaults.standard.bool(forKey: "invisible") == true {
|
UserDefaults.standard.register(defaults: ["icon": 2])
|
||||||
return
|
let icon = UserDefaults.standard.integer(forKey: "icon")
|
||||||
}
|
if icon == 0 { return }
|
||||||
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
if let button = self.statusItem.button {
|
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 = NSMenu(title: "")
|
||||||
|
self.statusItem.menu!.addItem(withTitle: "Memmon (v1.1)", 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusMenuIcon() -> NSImage {
|
|
||||||
let img = NSImage.init(size: .init(width: 21, height: 14), flipped: true) {
|
|
||||||
let ctx = NSGraphicsContext.current!.cgContext
|
|
||||||
let w = $0.width
|
|
||||||
let h = $0.height
|
|
||||||
let ssw = 0.025 * w // small stroke width
|
|
||||||
let lsw = 0.05 * w // large stroke width
|
|
||||||
// main screen
|
|
||||||
ctx.stroke(CGRect(x: 0.1 * w, y: 0.0 * h, width: 0.8 * w, height: 0.8 * h).insetBy(dx: lsw / 2, dy: lsw / 2), width: lsw)
|
|
||||||
ctx.clear(CGRect(x: 0.0 * w, y: 0.2 * h, width: 1.0 * w, height: 0.4 * h))
|
|
||||||
ctx.fill(CGRect(x: 0.41 * w, y: 0.8 * h, width: 0.18 * w, height: 0.12 * h))
|
|
||||||
ctx.fill(CGRect(x: 0.27 * w, y: 0.92 * h, width: 0.46 * w, height: 0.08 * h))
|
|
||||||
// three windows
|
|
||||||
ctx.stroke(CGRect(x: 0.0 * w, y: 0.28 * h, width: 0.27 * w, height: 0.24 * h).insetBy(dx: ssw / 2, dy: ssw / 2), width: ssw)
|
|
||||||
ctx.stroke(CGRect(x: 0.34 * w, y: 0.2 * h, width: 0.32 * w, height: 0.4 * h).insetBy(dx: ssw / 2, dy: ssw / 2), width: ssw)
|
|
||||||
ctx.stroke(CGRect(x: 0.73 * w, y: 0.28 * h, width: 0.27 * w, height: 0.24 * h).insetBy(dx: ssw / 2, dy: ssw / 2), width: ssw)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
img.isTemplate = true
|
|
||||||
return img
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func enableInvisbleMode() {
|
@objc func enableInvisbleMode() {
|
||||||
self.statusItem = nil
|
self.statusItem = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationDidChangeScreenParameters(_ notification: Notification) {
|
func applicationDidChangeScreenParameters(_ notification: Notification) {
|
||||||
if numScreens != NSScreen.screens.count {
|
if numScreens != NSScreen.screens.count {
|
||||||
// save state
|
self.saveState()
|
||||||
self.state[numScreens] = self.getState()
|
|
||||||
numScreens = NSScreen.screens.count
|
numScreens = NSScreen.screens.count
|
||||||
// restore state
|
|
||||||
if let previous = self.state[numScreens] {
|
if let previous = self.state[numScreens] {
|
||||||
self.restoreState(previous)
|
self.restoreState(previous)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getState() -> [Int32: [CGRect]] {
|
private func saveState() {
|
||||||
var state: [Int32: [CGRect]] = [:]
|
let newState = self.getState()
|
||||||
let windowList = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as NSArray? as? [[String: AnyObject]]
|
self.state[numScreens] = newState
|
||||||
for entry in windowList! {
|
// update existing
|
||||||
let pid = entry[kCGWindowOwnerPID as String] as! Int32
|
let dummy: WinPos = (0, CGRect.zero)
|
||||||
let layer = entry[kCGWindowLayer as String] as! Int32
|
for kNum in self.state.keys {
|
||||||
// let owner = entry[kCGWindowOwnerName as String] as! String
|
if kNum == numScreens { continue } // current state, already set above
|
||||||
if layer != 0 {
|
var tmp_state: WinConf = [:]
|
||||||
continue
|
for (n_app, new_val) in newState {
|
||||||
}
|
if let old_val = self.state[kNum]![n_app] {
|
||||||
let b = entry[kCGWindowBounds as String] as! [String: Int]
|
tmp_state[n_app] = []
|
||||||
let bounds = CGRect(x: b["X"]!, y: b["Y"]!, width: b["Width"]!, height: b["Height"]!)
|
for (n_win, _) in new_val {
|
||||||
if (state[pid] == nil) {
|
let old_pos = old_val.first { $0.0 == n_win }
|
||||||
state[pid] = [bounds]
|
tmp_state[n_app]!.append(old_pos ?? dummy)
|
||||||
} else {
|
}
|
||||||
state[pid]!.append(bounds)
|
}
|
||||||
}
|
}
|
||||||
|
self.state[kNum] = tmp_state
|
||||||
}
|
}
|
||||||
return state
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restoreState(_ state: [Int32: [CGRect]]) {
|
private func restoreState(_ state: WinConf) {
|
||||||
for (pid, bounds) in state {
|
for (pid, bounds) in state {
|
||||||
self.setWindowSizes(pid, bounds)
|
self.setWindowSizes(pid, bounds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setWindowSizes(_ pid: Int32, _ sizes: [CGRect]) {
|
private func getState() -> WinConf {
|
||||||
|
var allWinNums: [Int32] = []
|
||||||
|
for winNum in NSWindow.windowNumbers(options: [.allApplications, .allSpaces]) ?? [] {
|
||||||
|
allWinNums.append(winNum.int32Value)
|
||||||
|
}
|
||||||
|
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! Int32
|
||||||
|
guard let insIdx = allWinNums.firstIndex(of: winNum) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let pid = entry[kCGWindowOwnerPID as String] as! Int32
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setWindowSizes(_ pid: Int32, _ sizes: [WinPos]) {
|
||||||
let win = self.axWinList(pid)
|
let win = self.axWinList(pid)
|
||||||
guard win.count > 0, win.count == sizes.count else {
|
guard win.count > 0, win.count == sizes.count else {
|
||||||
print(pid, win.count, sizes.count)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for i in 0 ..< win.count {
|
for i in 0 ..< win.count {
|
||||||
var newPoint = sizes[i].origin
|
var pt = sizes[i].1
|
||||||
var newSize = sizes[i].size
|
if pt.isEmpty { continue } // filter dummy elements
|
||||||
AXUIElementSetAttributeValue(win[i], kAXPositionAttribute as CFString,
|
AXUIElementSetAttributeValue(win[i], kAXPositionAttribute as CFString,
|
||||||
AXValueCreate(AXValueType(rawValue: kAXValueCGPointType)!, &newPoint)!);
|
AXValueCreate(AXValueType(rawValue: kAXValueCGPointType)!, &pt.origin)!);
|
||||||
AXUIElementSetAttributeValue(win[i], kAXSizeAttribute as CFString,
|
AXUIElementSetAttributeValue(win[i], kAXSizeAttribute as CFString,
|
||||||
AXValueCreate(AXValueType(rawValue: kAXValueCGSizeType)!, &newSize)!);
|
AXValueCreate(AXValueType(rawValue: kAXValueCGSizeType)!, &pt.size)!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +137,48 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
let h = $0.height
|
||||||
|
let ssw = 0.025 * w // small stroke width
|
||||||
|
let lsw = 0.05 * w // large stroke width
|
||||||
|
// main screen
|
||||||
|
ctx.stroke(CGRect(x: 0.1 * w, y: 0.0 * h, width: 0.8 * w, height: 0.8 * h).insetBy(dx: lsw / 2, dy: lsw / 2), width: lsw)
|
||||||
|
ctx.clear(CGRect(x: 0.0 * w, y: 0.2 * h, width: 1.0 * w, height: 0.4 * h))
|
||||||
|
ctx.fill(CGRect(x: 0.41 * w, y: 0.8 * h, width: 0.18 * w, height: 0.12 * h))
|
||||||
|
ctx.fill(CGRect(x: 0.27 * w, y: 0.92 * h, width: 0.46 * w, height: 0.08 * h))
|
||||||
|
// three windows
|
||||||
|
ctx.stroke(CGRect(x: 0.0 * w, y: 0.28 * h, width: 0.27 * w, height: 0.24 * h).insetBy(dx: ssw / 2, dy: ssw / 2), width: ssw)
|
||||||
|
ctx.stroke(CGRect(x: 0.34 * w, y: 0.2 * h, width: 0.32 * w, height: 0.4 * h).insetBy(dx: ssw / 2, dy: ssw / 2), width: ssw)
|
||||||
|
ctx.stroke(CGRect(x: 0.73 * w, y: 0.28 * h, width: 0.27 * w, height: 0.24 * h).insetBy(dx: ssw / 2, dy: ssw / 2), width: ssw)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
img.isTemplate = true
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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