obsidian-tray/main.js
Claude 9a4c48d9ea
fix: prevent macOS shutdown blocking when tray icon is active
Added proper handling of app quit events to prevent blocking system
shutdown on macOS. When the system initiates shutdown, the app now
correctly allows windows to close instead of intercepting the close
event and hiding them to the tray.

Changes:
- Added isQuitting flag to track app quit state
- Modified onWindowClose to allow closing during quit
- Modified onWindowUnload to skip interception during quit
- Added before-quit event listener to set flag and cleanup

This fix ensures that when users shut down their Mac, Obsidian
will properly exit even when running in background with tray icon,
resolving the issue where shutdown would hang waiting for the app.
2025-11-12 00:20:11 +00:00

509 lines
17 KiB
JavaScript

/**
* obsidian-tray v0.3.5
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://github.com/dragonwocky/obsidian-tray/) under the MIT license
*/
"use strict";
const LOG_PREFIX = "obsidian-tray",
LOG_LOADING = "loading",
LOG_CLEANUP = "cleaning up",
LOG_SHOWING_WINDOWS = "showing windows",
LOG_HIDING_WINDOWS = "hiding windows",
LOG_WINDOW_CLOSE = "intercepting window close",
LOG_TRAY_ICON = "creating tray icon",
LOG_REGISTER_HOTKEY = "registering hotkey",
LOG_UNREGISTER_HOTKEY = "unregistering hotkey",
ACTION_QUICK_NOTE = "Quick Note",
ACTION_SHOW = "Show Vault",
ACTION_HIDE = "Hide Vault",
ACTION_RELAUNCH = "Relaunch Obsidian",
ACTION_CLOSE = "Close Vault",
DEFAULT_DATE_FORMAT = "YYYY-MM-DD",
ACCELERATOR_FORMAT = `
This hotkey is registered globally and will be detected even if Obsidian does
not have keyboard focus. Format:
<a href="https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts#accelerators" target="_blank" rel="noopener">
Electron accelerator</a>
`,
MOMENT_FORMAT = `
Format:
<a href="https://momentjs.com/docs/#/displaying/format/" target="_blank" rel="noopener">
Moment.js format string</a>
`,
// 16x16 base64 obsidian icon: generated from obsidian.asar/icon.png
OBSIDIAN_BASE64_ICON = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHZSURBVDhPlZKxTxRBFMa/XZcF7nIG7mjxjoRCwomJxgsFdhaASqzQxFDzB1AQKgstLGxIiBQGJBpiCCGx8h+wgYaGgAWNd0dyHofeEYVwt/PmOTMZV9aDIL/s5pvZvPfN9yaL/+HR3eXcypta0m4juFbP5GHuXc9IbunDFc9db/G81/ZzhDMN7g8td47mll4R5BfHwZN4LOaA+fHa259PbUmIYzWkt3e2NZNo3/V9v1vvU6kkstk+tLW3ItUVr/m+c3N8MlkwxYqmBFcbwUQQCNOcyVzDwEAWjuPi5DhAMV/tKOYPX5hCyz8Gz1zX5SmWjBvZfmTSaRBJkGAIoxJHv+pVW2yIGNxOJ8bUVNcFEWLxuG1ia6JercTbttwQTeDwPS0kCMXiXtgk/jQrFUw7ptYSMWApF40yo/ytjHq98fdk3ayVE+cn2CxMb6ruz9qAJKFUKoWza1VJSi/n0+ffgYHdWW2gHuxXymg0gjCB0sjpmiaDnkL3RzDyzLqBUKns2ztQqUR0fk2TwSrGSf1eczqF5vsPZRCQSSAFLk6gqctgQRkc6TWRQLV2YMYQki9OoNkqzFQ9r+WOGuW5CrJbOzyAlPKr6MSGLbkcDwbf35oY/jRkt6cAfgNwowruAMz9AgAAAABJRU5ErkJggg==`,
log = (message) => console.log(`${LOG_PREFIX}: ${message}`);
let tray, plugin, isQuitting = false;
const obsidian = require("obsidian"),
{ app, Tray, Menu } = require("electron").remote,
{ nativeImage, BrowserWindow } = require("electron").remote,
{ getCurrentWindow, globalShortcut } = require("electron").remote;
const vaultWindows = new Set(),
maximizedWindows = new Set(),
getWindows = () => [...vaultWindows],
observeWindows = () => {
const onWindowCreation = (win) => {
vaultWindows.add(win);
win.setSkipTaskbar(plugin.settings.hideTaskbarIcon);
win.on("close", () => {
if (win !== getCurrentWindow()) vaultWindows.delete(win);
});
// preserve maximised windows after minimisation
if (win.isMaximized()) maximizedWindows.add(win);
win.on("maximize", () => maximizedWindows.add(win));
win.on("unmaximize", () => maximizedWindows.delete(win));
};
onWindowCreation(getCurrentWindow());
getCurrentWindow().webContents.on("did-create-window", onWindowCreation);
if (process.platform === "darwin") {
// on macos, the "hide taskbar icon" option is implemented
// via app.dock.hide(): thus, the app as a whole will be
// hidden from the dock, including windows from other vaults.
// when a vault is closed via the "close vault" button,
// the cleanup process will call app.dock.show() to restore
// access to any other open vaults w/out the tray enabled
// => thus, this listener is required to re-hide the dock
// if switching to another vault with the option enabled
getCurrentWindow().on("focus", () => {
if (plugin.settings.hideTaskbarIcon) hideTaskbarIcons();
});
}
},
showWindows = () => {
log(LOG_SHOWING_WINDOWS);
getWindows().forEach((win) => {
if (maximizedWindows.has(win)) {
win.maximize();
win.focus();
} else win.show();
});
},
hideWindows = () => {
log(LOG_HIDING_WINDOWS);
getWindows().forEach((win) => [
win.isFocused() && win.blur(),
plugin.settings.runInBackground ? win.hide() : win.minimize(),
]);
},
toggleWindows = (checkForFocus = true) => {
const openWindows = getWindows().some((win) => {
return (!checkForFocus || win.isFocused()) && win.isVisible();
});
if (openWindows) hideWindows();
else showWindows();
};
const onWindowClose = (event) => {
// allow window to close during app quit (e.g., system shutdown)
// to prevent blocking macOS shutdown process
if (!isQuitting) event.preventDefault();
},
onWindowUnload = (event) => {
// allow window to close during app quit
if (isQuitting) return;
log(LOG_WINDOW_CLOSE);
getCurrentWindow().hide();
event.stopImmediatePropagation();
// setting return value manually is more reliable than
// via `return false` according to electron
event.returnValue = false;
},
interceptWindowClose = () => {
// intercept in renderer
window.addEventListener("beforeunload", onWindowUnload, true);
// intercept in main: is asynchronously executed when registered
// from renderer, so won't prevent close by itself, but counteracts
// the 3-second delayed window force close in obsidian.asar/main.js
getCurrentWindow().on("close", onWindowClose);
// listen for app quit events to allow proper shutdown
// especially important for macOS system shutdown
app.on("before-quit", () => {
log("preparing for app quit");
isQuitting = true;
allowWindowClose();
});
},
allowWindowClose = () => {
getCurrentWindow().removeListener("close", onWindowClose);
window.removeEventListener("beforeunload", onWindowUnload, true);
};
const hideTaskbarIcons = () => {
getWindows().forEach((win) => win.setSkipTaskbar(true));
if (process.platform === "darwin") app.dock.hide();
},
showTaskbarIcons = () => {
getWindows().forEach((win) => win.setSkipTaskbar(false));
if (process.platform === "darwin") app.dock.show();
},
setLaunchOnStartup = () => {
const { launchOnStartup, runInBackground, hideOnLaunch } = plugin.settings;
app.setLoginItemSettings({
openAtLogin: launchOnStartup,
openAsHidden: runInBackground && hideOnLaunch,
});
};
const cleanup = () => {
log(LOG_CLEANUP);
unregisterHotkeys();
showTaskbarIcons();
allowWindowClose();
destroyTray();
},
relaunchApp = () => {
app.relaunch();
app.exit(0);
},
closeVault = () => {
log(LOG_CLEANUP);
cleanup();
const vaultWindows = getWindows(),
obsidianWindows = BrowserWindow.getAllWindows();
if (obsidianWindows.length === vaultWindows.length) {
// quit app directly if only remaining windows are in the
// current vault - necessary for successful quit on macos
app.quit();
} else vaultWindows.forEach((win) => win.destroy());
};
const addQuickNote = () => {
const { quickNoteLocation, quickNoteDateFormat } = plugin.settings,
pattern = quickNoteDateFormat || DEFAULT_DATE_FORMAT,
date = obsidian.moment().format(pattern),
name = obsidian
.normalizePath(`${quickNoteLocation ?? ""}/${date}`)
.replace(/\*|"|\\|<|>|:|\||\?/g, "-"),
// manually create and open file instead of depending
// on createAndOpenMarkdownFile to force file creation
// relative to the root instead of the active file
// (in case user has default location for new notes
// set to "same folder as current file")
leaf = plugin.app.workspace.getLeaf(),
root = plugin.app.fileManager.getNewFileParent(""),
openMode = { active: true, state: { mode: "source" } };
plugin.app.fileManager
.createNewMarkdownFile(root, name)
.then((file) => leaf.openFile(file, openMode));
showWindows();
},
replaceVaultName = (str) => {
return str.replace(/{{vault}}/g, plugin.app.vault.getName());
},
destroyTray = () => {
tray?.destroy();
tray = undefined;
},
createTrayIcon = () => {
destroyTray();
if (!plugin.settings.createTrayIcon) return;
log(LOG_TRAY_ICON);
const obsidianIcon = nativeImage.createFromDataURL(
plugin.settings.trayIconImage ?? OBSIDIAN_BASE64_ICON
),
contextMenu = Menu.buildFromTemplate([
{
type: "normal",
label: ACTION_QUICK_NOTE,
accelerator: plugin.settings.quickNoteHotkey,
click: addQuickNote,
},
{
type: "normal",
label: ACTION_SHOW,
accelerator: plugin.settings.toggleWindowFocusHotkey,
click: showWindows,
},
{
type: "normal",
label: ACTION_HIDE,
accelerator: plugin.settings.toggleWindowFocusHotkey,
click: hideWindows,
},
{ type: "separator" },
{ label: ACTION_RELAUNCH, click: relaunchApp },
{ label: ACTION_CLOSE, click: closeVault },
]);
tray = new Tray(obsidianIcon);
tray.setContextMenu(contextMenu);
tray.setToolTip(replaceVaultName(plugin.settings.trayIconTooltip));
tray.on("click", () => {
if (process.platform === "darwin") {
// macos does not register separate left/right click actions
// for menu items, icon should open menu w/out causing toggle
tray.popUpContextMenu();
} else toggleWindows(false);
});
};
const registerHotkeys = () => {
log(LOG_REGISTER_HOTKEY);
try {
const { toggleWindowFocusHotkey, quickNoteHotkey } = plugin.settings;
if (toggleWindowFocusHotkey) {
globalShortcut.register(toggleWindowFocusHotkey, toggleWindows);
}
if (quickNoteHotkey) {
globalShortcut.register(quickNoteHotkey, addQuickNote);
}
} catch {}
},
unregisterHotkeys = () => {
log(LOG_UNREGISTER_HOTKEY);
try {
globalShortcut.unregister(plugin.settings.toggleWindowFocusHotkey);
globalShortcut.unregister(plugin.settings.quickNoteHotkey);
} catch {}
};
const OPTIONS = [
"Window management",
{
key: "launchOnStartup",
desc: "Open Obsidian automatically whenever you log into your computer.",
type: "toggle",
default: false,
onChange: setLaunchOnStartup,
},
{
key: "hideOnLaunch",
desc: `
Minimises Obsidian automatically whenever the app is launched. If the
"Run in background" option is enabled, windows will be hidden to the system
tray/menubar instead of minimised to the taskbar/dock.
`,
type: "toggle",
default: false,
},
{
key: "runInBackground",
desc: `
Hides the app and continues to run it in the background instead of quitting
it when pressing the window close button or toggle focus hotkey.
`,
type: "toggle",
default: false,
onChange() {
setLaunchOnStartup();
if (plugin.settings.runInBackground) interceptWindowClose();
else [allowWindowClose(), showWindows()];
},
},
{
key: "hideTaskbarIcon",
desc: `
Hides the window's icon from from the dock/taskbar. Enabling the tray icon first
is recommended if using this option. This may not work on Linux-based OSes.
`,
type: "toggle",
default: false,
onChange() {
if (plugin.settings.hideTaskbarIcon) hideTaskbarIcons();
else showTaskbarIcons();
},
},
{
key: "createTrayIcon",
desc: `
Adds an icon to your system tray/menubar to bring hidden Obsidian windows
back into focus on click or force a full quit/relaunch of the app through
the right-click menu.
`,
type: "toggle",
default: true,
onChange: createTrayIcon,
},
{
key: "trayIconImage",
desc: `
Set the image used by the tray/menubar icon. Recommended size: 16x16
<br>Preview: <img data-preview style="height: 16px; vertical-align: bottom;">
`,
type: "image",
default: OBSIDIAN_BASE64_ICON,
onChange: createTrayIcon,
},
{
key: "trayIconTooltip",
desc: `
Set a title to identify the tray/menubar icon by. The
<code>{{vault}}</code> placeholder will be replaced by the vault name.
<br>Preview: <b class="u-pop" data-preview></b>
`,
type: "text",
default: "{{vault}} | Obsidian",
postprocessor: replaceVaultName,
onChange: createTrayIcon,
},
{
key: "toggleWindowFocusHotkey",
desc: ACCELERATOR_FORMAT,
type: "hotkey",
default: "CmdOrCtrl+Shift+Tab",
onBeforeChange: unregisterHotkeys,
onChange: registerHotkeys,
},
"Quick notes",
{
key: "quickNoteLocation",
desc: "New quick notes will be placed in this folder.",
type: "text",
placeholder: "Example: notes/quick",
},
{
key: "quickNoteDateFormat",
desc: `
New quick notes will use a filename of this pattern. ${MOMENT_FORMAT}
<br>Preview: <b class="u-pop" data-preview></b>
`,
type: "moment",
default: DEFAULT_DATE_FORMAT,
},
{
key: "quickNoteHotkey",
desc: ACCELERATOR_FORMAT,
type: "hotkey",
default: "CmdOrCtrl+Shift+Q",
onBeforeChange: unregisterHotkeys,
onChange: registerHotkeys,
},
];
const keyToLabel = (key) =>
key[0].toUpperCase() +
key
.slice(1)
.split(/(?=[A-Z])/)
.map((word) => word.toLowerCase())
.join(" "),
htmlToFragment = (html) =>
document
.createRange()
.createContextualFragment((html ?? "").replace(/\s+/g, " "));
class SettingsTab extends obsidian.PluginSettingTab {
display() {
this.containerEl.empty();
for (const opt of OPTIONS) {
const setting = new obsidian.Setting(this.containerEl);
if (typeof opt === "string") {
setting.setName(opt);
setting.setHeading();
} else {
if (opt.default) opt.placeholder ??= `Example: ${opt.default}`;
setting.setName(keyToLabel(opt.key));
setting.setDesc(htmlToFragment(opt.desc));
const onChange = async (value) => {
await opt.onBeforeChange?.();
plugin.settings[opt.key] = value;
await plugin.saveSettings();
await opt.onChange?.();
};
const value = plugin.settings[opt.key] ?? opt.default ?? "";
if (opt.type === "toggle") {
setting.addToggle((toggle) => {
toggle.setValue(value).onChange(onChange);
});
} else if (opt.type === "image") {
const previewImg = setting.descEl.querySelector("img[data-preview");
if (previewImg) previewImg.src = value;
const fileUpload = setting.descEl.createEl("input");
fileUpload.style.visibility = "hidden";
fileUpload.type = "file";
fileUpload.onchange = (event) => {
const file = event.target.files[0],
reader = new FileReader();
reader.onloadend = () => {
onChange(reader.result);
if (previewImg) previewImg.src = reader.result;
};
reader.readAsDataURL(file);
};
setting.addButton((button) => {
button.setIcon("image").onClick(() => fileUpload.click());
});
} else if (opt.type === "moment") {
setting.addMomentFormat((moment) => {
const previewEl = setting.descEl.querySelector("[data-preview]");
if (previewEl) moment.setSampleEl(previewEl);
moment
.setPlaceholder(opt.placeholder)
.setDefaultFormat(opt.default ?? "")
.setValue(value)
.onChange(onChange);
});
} else {
const previewEl = setting.descEl.querySelector("[data-preview]"),
updatePreview = (value) => {
if (!previewEl) return;
previewEl.innerText = opt?.postprocessor(value) ?? value;
};
updatePreview(value);
setting.addText((text) => {
text
.setPlaceholder(opt.placeholder)
.setValue(value)
.onChange((value) => [onChange(value), updatePreview(value)]);
});
}
}
}
}
}
class TrayPlugin extends obsidian.Plugin {
async onload() {
log(LOG_LOADING);
await this.loadSettings();
this.addSettingTab(new SettingsTab(this.app, this));
const { settings } = this;
plugin = this;
createTrayIcon();
registerHotkeys();
setLaunchOnStartup();
observeWindows();
if (settings.runInBackground) interceptWindowClose();
if (settings.hideTaskbarIcon) hideTaskbarIcons();
if (settings.hideOnLaunch) {
this.registerEvent(this.app.workspace.onLayoutReady(hideWindows));
}
// add as command: can be called from command palette
// and can have non-global hotkey assigned via in-app menu
this.addCommand({
id: "relaunch-app",
name: ACTION_RELAUNCH,
callback: relaunchApp,
});
this.addCommand({
id: "close-vault",
name: ACTION_CLOSE,
callback: closeVault,
});
}
onunload() {
cleanup();
}
getCurrentWindow = getCurrentWindow
getWindows = getWindows;
showWindows = showWindows;
hideWindows = hideWindows;
toggleWindows = toggleWindows;
async loadSettings() {
const DEFAULT_SETTINGS = OPTIONS.map((opt) => ({ [opt.key]: opt.default }));
this.settings = Object.assign(...DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
module.exports = TrayPlugin;