README file from
GithubClose Reflect
Intercept Obsidian's quit and show a reflection prompt before the window closes — a chance to look at where you are before you leave.
The prompt has an editable title, Markdown body, and as many action buttons as you want. Buttons can cancel the quit, let the app close, open a note, run a command, or open a URL.
The body is real Markdown, so wikilinks, external links, embeds, Meta Bind controls and task checkboxes all work inside it — and embeds are clickable anywhere on their body, which they are not by default. Clicking something that does anything cancels the quit and steps the prompt aside: you are going somewhere, not leaving.
The plugin depends on Obsidian internals that a future release could change — see Limitations.
How the interception works
Obsidian does not make this easy, and the interesting part of this plugin is the three obstacles it works around. All three were established by reading Obsidian 1.13.7's own bundle, and each one is verified by the plugin's own developer log.
1. Obsidian's quit hook erases itself. The workspace quit event is fired from inside
Obsidian's own window.onbeforeunload hook, and that hook starts by setting
window.onbeforeunload = null so the window.close() it re-issues cannot loop back into
it. One session therefore gets exactly one quit event. The plugin captures the hook before
its first run and puts it back after a quit it cancelled, which is what lets the second
close be intercepted too.
2. Obsidian force-closes any window that survives a close for three seconds. Its main
process arms setTimeout(() => { !v.defaultPrevented && g.destroy() }, 3000) on every
window close, and destroy() bypasses beforeunload entirely. The catch is that
close.defaultPrevented is only ever written by a main-process listener calling
preventDefault() — a renderer's beforeunload cancellation travels a different channel
(will-prevent-unload) and never marks it. Obsidian's own bootstrap hands the renderer the
window through @electron/remote (window.electronWindow), so the plugin joins that same
close event and marks it once it has decided to hold — which makes the timer stand down.
The mark has to happen after dispatch, not during it: preventing the close outright would
stop beforeunload from running at all, and the prompt would never appear.
3. Holding the quit strands Obsidian's "Saving..." screen. That screen lives on a
module-private singleton inside Obsidian's bundle and is only taken down by the very
window.close() the plugin swallows. There is no way to reach the instance, so the plugin
repeats what its hide() does — remove the container and drop the in-progress body class.
Every failure path fails open: an unanswered prompt follows the configured timeout action, and a prompt that could not be shown at all releases the quit, so a broken overlay can never leave the app unclosable.
On mobile
Mobile has no quit to intercept — the quit event is fired from inside Obsidian's Electron
hook, which mobile does not have. What it has instead are gestures, and the plugin answers
two of them, each behind its own toggle:
| Gesture | What happens |
|---|---|
| Back button | The prompt appears when the back button would leave the app. A press with a note to go back to, or a sidebar to collapse, is navigation and is left alone — the plugin mirrors the condition Obsidian's own handler uses. |
| Going back to the home screen | The prompt is raised as the app is backgrounded, and is waiting when you come back. |
There is an asymmetry worth knowing. Capacitor delivers the back button to every listener and offers no way to stop the others, so this runs alongside Obsidian's handler rather than replacing it: pressing back twice in quick succession still sends the app to the background, because that second press is Obsidian's. And backgrounding cannot be cancelled at all — the app is already gone — which is why that second toggle is off by default and why "cancel the quit" on mobile simply means the prompt closes.
The obvious way to take the gesture over, removeAllListeners(), is not used: it drops every
other listener Obsidian registered for that plugin too — URI opens, share intents, app state
— and leaves the app worse off.
The edit modal
Settings keep the behavioural options. The title, the text and the buttons are edited in a
dedicated modal: editor on the left (a real Obsidian Markdown editor, with live preview and
[[ completion), live preview on the right, Save and Cancel at the bottom. Everything is
edited on a draft, so Cancel really discards.
The modal adapts to the content source:
| Inline text | Linked note | |
|---|---|---|
| Editor | the overlay text | the note's body, frontmatter excluded |
| Save | writes the text into the settings | writes the body back into the note, keeping its frontmatter |
In linked mode the note is only written when its body actually changed, and only if it was readable in the first place — a note that could not be read is never written over.
The embedded editor borrows Obsidian's own CodeMirror through the module table Obsidian exposes to plugins, so no second copy of CodeMirror is bundled and Obsidian's internal editor state fields keep working. If that ever stops being available the modal falls back to a plain textarea.
Settings
| Setting | Meaning |
|---|---|
| Content source | Write the text inline, or render it from a note in the vault. |
| Title, text and buttons | Opens the edit modal. |
| Linked note | The note whose contents the overlay shows. Start typing to search. |
| Remembered copy | A copy of the linked note, used when the note cannot be read. |
| Interceptions before release | How many quits to interrupt. Once spent, later quits close without asking. |
| Release timeout | How long to wait for an answer. |
| When the timeout expires | Cancel the quit and stay open, or let the app close. Cancelling is the default. |
| Write diagnostics to a file | Appends one entry per quit attempt inside the vault. |
| Developer log | Echoes diagnostics to the console and writes a step-by-step trace to close-reflect-trace.log in the system temp folder. |
| Ask on the back button | Mobile only. |
| Ask on going back to the home screen | Mobile only, off by default. |
Limitations
- The
quitevent is documented as "Not guaranteed to actually run." Crashes, an externalkill, or a priority shutdown will skip the interception. This is best-effort. - Registering a quit task breaks Reload app without saving: the reload command closes the window instead of reloading. Disable the plugin if you need that command.
- The interception relies on Obsidian internals (
@electron/remote, the shape of its quit hook, the progress-screen markup, and on mobile the Capacitor bridge and the shape of Obsidian's own back handling). A future Obsidian release could change any of them; each is guarded and falls back rather than breaking the app. - The prompt is a speed bump, not a lock: on desktop it holds the quit, but on mobile Obsidian's own second back press still backgrounds the app.
Data and privacy
- No network access. No telemetry. No analytics.
- With Write diagnostics to a file on, the plugin appends one entry per quit attempt to a vault-relative path you choose.
- With Developer log on, it also writes a step-by-step trace to
close-reflect-trace.login the system temp folder. Both are off by default.
Development
bun install
bun run build # bundles src/main.ts -> main.js
bun run typecheck # tsc --noEmit
Copy main.js, manifest.json and styles.css into
<vault>/.obsidian/plugins/close-reflect/ and enable the plugin.
Credits
The embedded Markdown editor in src/markdown-editor.ts is ported from
EmbeddableMarkdownEditor
by Fevol, with the prototype-extraction technique originally from
mgmeyers/obsidian-kanban — both MIT licensed.
Only that MIT-licensed editor-construction code was carried over.
License
MIT