From b913fd1da0ad78631b756913a1fc07fd501ca970 Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Thu, 2 Jul 2026 16:33:25 +0200 Subject: [PATCH 1/2] Add a tray icon with a menu of all notes Adds an implementation of the StatusNotifierItem and com.canonical.dbusmenu D-Bus specifications, since GTK4 has no native tray icon support. The tray menu offers the list of notes (newest first, sharing the application window's sort order, each with a color swatch matching the note's style) and an item that opens the application window with the search entry focused. While a tray host is present, the application holds itself so that closing all windows keeps it running and reachable from the tray; without a tray host, behavior is unchanged. Also grants the Flatpak sandbox permission to talk to the StatusNotifierWatcher, and documents in the README and the Flathub app description that GNOME Shell needs the AppIndicator extension to show the tray icon. Menu items keep stable ids across rebuilds because hosts cache items by id; in-place changes are announced with ItemsPropertiesUpdated, and AboutToShow asks for a refresh since hosts defer layout updates while the menu is closed. Notes whose relative order changed get fresh ids, so that hosts process the reorder as remove and add: the AppIndicator GNOME Shell extension silently drops moves of existing root-level items, because its child-moved handler declares the signal arguments in the wrong order. --- README.md | 12 + build-aux/flatpak/com.vixalien.sticky.json | 1 + data/com.vixalien.sticky.appdata.xml.in.in | 7 + po/POTFILES | 1 + src/application.ts | 28 +- src/com.vixalien.sticky.src.gresource.xml | 1 + src/meson.build | 1 + src/notes.ts | 14 +- src/trayicon.ts | 560 +++++++++++++++++++++ src/util.ts | 4 + 10 files changed, 621 insertions(+), 8 deletions(-) create mode 100644 src/trayicon.ts diff --git a/README.md b/README.md index 431c2b5..459b4a7 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,18 @@ written in GJS and uses GTK4. +## Tray icon + +Sticky Notes provides a tray icon with quick access to all your notes. It uses +the freedesktop [StatusNotifierItem][spec] specification, which KDE Plasma and +most other desktop environments support out of the box. GNOME Shell does not: +to see the tray icon on GNOME, install and enable the +[AppIndicator and KStatusNotifierItem Support][extension] extension. +Without it, the app still works normally — the tray icon just won't be shown. + +[spec]: https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/ +[extension]: https://extensions.gnome.org/extension/615/appindicator-support/ + ## Installation ### From Flathub diff --git a/build-aux/flatpak/com.vixalien.sticky.json b/build-aux/flatpak/com.vixalien.sticky.json index 298e3e6..6377eb5 100644 --- a/build-aux/flatpak/com.vixalien.sticky.json +++ b/build-aux/flatpak/com.vixalien.sticky.json @@ -18,6 +18,7 @@ "--device=dri", "--socket=wayland", "--socket=fallback-x11", + "--talk-name=org.kde.StatusNotifierWatcher", "--env=GJS_DISABLE_JIT=1" ], "cleanup": [ diff --git a/data/com.vixalien.sticky.appdata.xml.in.in b/data/com.vixalien.sticky.appdata.xml.in.in index 14ef9ee..5de332c 100644 --- a/data/com.vixalien.sticky.appdata.xml.in.in +++ b/data/com.vixalien.sticky.appdata.xml.in.in @@ -16,7 +16,14 @@
  • notes are restored if they were open when the application was closed
  • changing color of notes
  • dark theme support
  • +
  • tray icon with quick access to all notes
  • +

    + The tray icon requires a status notifier host: it works out of the box on + KDE Plasma and most other desktop environments, but on GNOME you need to + install and enable the "AppIndicator and KStatusNotifierItem Support" + extension to see it. +

    diff --git a/po/POTFILES b/po/POTFILES index 4758d86..ee4d199 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -13,6 +13,7 @@ src/card.ts src/main.ts src/notes.ts src/styleselector.ts +src/trayicon.ts src/util.ts src/view.ts src/window.ts diff --git a/src/application.ts b/src/application.ts index 8d29ddc..80c7010 100644 --- a/src/application.ts +++ b/src/application.ts @@ -31,7 +31,7 @@ import Gtk from "gi://Gtk?version=4.0"; import Gdk from "gi://Gdk?version=4.0"; import { StickyNotes } from "./notes.js"; -import { Note, settings } from "./util.js"; +import { compare_notes_newest_first, Note, settings } from "./util.js"; import { delete_note, load_notes, @@ -40,10 +40,13 @@ import { save_notes, } from "./store.js"; import { Window } from "./window.js"; +import { TrayIcon } from "./trayicon.js"; export class Application extends Adw.Application { private window: StickyNotes | null = null; private note_windows: Window[] = []; + private tray: TrayIcon | null = null; + private tray_holding = false; static { GObject.registerClass(this); @@ -195,7 +198,7 @@ export class Application extends Adw.Application { if (!has_one_open) { const last_open_note = this.notes_array() - .sort((a, b) => b.modified.compare(a.modified))[0]; + .sort(compare_notes_newest_first)[0]; if (has_one_open) { this.show_note(last_open_note.uuid); @@ -539,9 +542,30 @@ export class Application extends Adw.Application { window.present(); } + search_notes() { + this.all_notes(); + this.window?.focus_search(); + } + vfunc_startup() { super.vfunc_startup(); + this.tray = new TrayIcon(this); + // keep the application running without windows while the tray icon is + // shown, so that it stays reachable from the tray + this.tray.registered_changed = (registered) => { + if (registered === this.tray_holding) return; + + if (registered) { + this.hold(); + } else { + this.release(); + } + + this.tray_holding = registered; + }; + this.tray.start(); + const style_manager = Adw.StyleManager.get_default(); function setColorScheme() { const color_scheme = settings.get_int("color-scheme"); diff --git a/src/com.vixalien.sticky.src.gresource.xml b/src/com.vixalien.sticky.src.gresource.xml index 8062fda..024f401 100644 --- a/src/com.vixalien.sticky.src.gresource.xml +++ b/src/com.vixalien.sticky.src.gresource.xml @@ -9,6 +9,7 @@ styleselector.js store.js themeselector.js + trayicon.js view.js window.js util.js diff --git a/src/meson.build b/src/meson.build index c1ac862..0e23ec1 100644 --- a/src/meson.build +++ b/src/meson.build @@ -9,6 +9,7 @@ sources = [ 'styleselector.ts', 'store.ts', 'themeselector.ts', + 'trayicon.ts', 'view.ts', 'window.ts', 'util.ts' diff --git a/src/notes.ts b/src/notes.ts index 5d21528..d59c050 100644 --- a/src/notes.ts +++ b/src/notes.ts @@ -29,7 +29,7 @@ import Adw from "gi://Adw"; import type { Application } from "./application.js"; import { StickyNoteCard } from "./card.js"; -import { Note, settings } from "./util.js"; +import { compare_notes_newest_first, Note, settings } from "./util.js"; import { ThemeSelector } from "./themeselector.js"; @@ -156,11 +156,9 @@ export class StickyNotes extends Adw.ApplicationWindow { filter, ); - this.sorter = Gtk.CustomSorter.new((note1, note2) => { - const date1 = (note1 as Note).modified; - const date2 = (note2 as Note).modified; - return date2.compare(date1); - }); + this.sorter = Gtk.CustomSorter.new((note1, note2) => + compare_notes_newest_first(note1 as Note, note2 as Note) + ); const sorter_model = Gtk.SortListModel.new(filter_model, this.sorter); @@ -199,6 +197,10 @@ export class StickyNotes extends Adw.ApplicationWindow { popover.add_child(new ThemeSelector(), "themeswitcher"); } + focus_search() { + this._search_entry.grab_focus(); + } + set_status() { if (this.last_model.get_n_items() > 0) { this.set_visible_child(this._notes_box); diff --git a/src/trayicon.ts b/src/trayicon.ts new file mode 100644 index 0000000..52cdb10 --- /dev/null +++ b/src/trayicon.ts @@ -0,0 +1,560 @@ +/* MIT License + * + * Copyright (c) 2026 Sticky Notes contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * SPDX-License-Identifier: MIT + */ + +import Gio from "gi://Gio"; +import GLib from "gi://GLib"; +import GdkPixbuf from "gi://GdkPixbuf"; + +import type { Application } from "./application.js"; +import { compare_notes_newest_first, Note, Style } from "./util.js"; + +// GTK4 has no tray icon support, so the icon is provided over D-Bus using the +// StatusNotifierItem specification, and its menu using the com.canonical.dbusmenu +// specification. See: +// https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/ +// https://github.com/AyatanaIndicators/libdbusmenu/blob/master/libdbusmenu-glib/dbus-menu.xml + +const WATCHER_BUS_NAME = "org.kde.StatusNotifierWatcher"; +const WATCHER_OBJECT_PATH = "/StatusNotifierWatcher"; +const ITEM_OBJECT_PATH = "/StatusNotifierItem"; +const MENU_OBJECT_PATH = "/com/vixalien/sticky/TrayMenu"; + +const StatusNotifierItemInterface = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +const DBusMenuInterface = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +// the "header" color of each note style, used to draw the menu swatches +const STYLE_COLORS = new Map([ + [Style.yellow, "#ffee92"], + [Style.pink, "#ffc5e1"], + [Style.green, "#d1ebcb"], + [Style.purple, "#e2d0f5"], + [Style.blue, "#cce7ff"], + [Style.gray, "#dddad8"], + [Style.charcoal, "#3f3d3c"], + [Style.window, "#deddda"], +]); + +const SWATCH_SIZE = 16; +const SWATCH_BORDER = 1; + +// hosts cache menu items by id, so ids must be stable across rebuilds +const SEPARATOR_ID = 1; +const OPEN_ID = 2; +const FIRST_NOTE_ID = 3; + +interface TrayMenuItem { + id: number; + properties: Record; + activate?: () => void; +} + +// in dbusmenu labels, a single underscore marks the mnemonic character +const escape_label = (label: string) => label.replace(/_/g, "__"); + +export class TrayIcon { + private app: Application; + private item_dbus_object: Gio.DBusExportedObject | null = null; + private menu_dbus_object: Gio.DBusExportedObject | null = null; + private watcher_id: number | null = null; + private items: TrayMenuItem[] = []; + private note_order: string[] = []; + private note_ids = new Map(); + private next_note_id = FIRST_NOTE_ID; + private revision = 0; + private rebuild_source: number | null = null; + private connected_notes = new WeakSet(); + private swatches = new Map(); + + registered = false; + registered_changed: ((registered: boolean) => void) | null = null; + + constructor(app: Application) { + this.app = app; + + this.rebuild_menu(); + + this.app.notes_list.connect("items-changed", () => { + this.watch_notes(); + this.invalidate_menu(); + }); + this.watch_notes(); + } + + start() { + try { + this.export(); + } catch (error) { + console.error("Failed to export tray icon on D-Bus:", error as any); + return; + } + + this.watcher_id = Gio.bus_watch_name( + Gio.BusType.SESSION, + WATCHER_BUS_NAME, + Gio.BusNameWatcherFlags.NONE, + () => this.register(), + () => this.set_registered(false), + ); + } + + private export() { + this.item_dbus_object = Gio.DBusExportedObject.wrapJSObject( + StatusNotifierItemInterface, + this.make_item_implementation(), + ); + this.item_dbus_object.export(Gio.DBus.session, ITEM_OBJECT_PATH); + + this.menu_dbus_object = Gio.DBusExportedObject.wrapJSObject( + DBusMenuInterface, + this.make_menu_implementation(), + ); + this.menu_dbus_object.export(Gio.DBus.session, MENU_OBJECT_PATH); + } + + private register() { + Gio.DBus.session.call( + WATCHER_BUS_NAME, + WATCHER_OBJECT_PATH, + WATCHER_BUS_NAME, + "RegisterStatusNotifierItem", + // registering the object path (instead of a bus name) works with all + // modern hosts and doesn't require owning a second name on the bus + new GLib.Variant("(s)", [ITEM_OBJECT_PATH]), + null, + Gio.DBusCallFlags.NONE, + -1, + null, + (connection, result) => { + try { + connection!.call_finish(result); + this.set_registered(true); + } catch (error) { + console.error( + "Failed to register with the status notifier watcher:", + error as any, + ); + this.set_registered(false); + } + }, + ); + } + + private set_registered(registered: boolean) { + if (this.registered === registered) return; + this.registered = registered; + this.registered_changed?.(registered); + } + + private make_item_implementation() { + return { + Category: "ApplicationStatus", + Id: pkg.name, + Title: _("Sticky Notes"), + Status: "Active", + WindowId: 0, + IconName: pkg.name, + OverlayIconName: "", + AttentionIconName: "", + AttentionMovieName: "", + ItemIsMenu: false, + Menu: MENU_OBJECT_PATH, + Activate: (_x: number, _y: number) => { + this.app.all_notes(); + }, + SecondaryActivate: (_x: number, _y: number) => { + this.app.all_notes(); + }, + ContextMenu: (_x: number, _y: number) => { + // the host displays the Menu itself + }, + Scroll: (_delta: number, _orientation: string) => {}, + }; + } + + private make_menu_implementation() { + return { + Version: 3, + TextDirection: "ltr", + Status: "normal", + IconThemePath: [] as string[], + GetLayout: ( + parent_id: number, + recursion_depth: number, + _property_names: string[], + ) => { + return GLib.Variant.new_tuple([ + GLib.Variant.new_uint32(this.revision), + this.layout_variant(parent_id, recursion_depth), + ]); + }, + GetGroupProperties: (ids: number[], _property_names: string[]) => { + const rows = this.items + .filter((item) => ids.length === 0 || ids.includes(item.id)) + .map((item) => [item.id, item.properties]); + + if (ids.length === 0 || ids.includes(0)) { + rows.unshift([0, this.root_properties()]); + } + + return new GLib.Variant("(a(ia{sv}))", [rows]); + }, + GetProperty: (id: number, name: string) => { + const value = id === 0 + ? this.root_properties()[name] + : this.items.find((item) => item.id === id)?.properties[name]; + + return value ?? new GLib.Variant("s", ""); + }, + Event: ( + id: number, + event_id: string, + _data: GLib.Variant, + _timestamp: number, + ) => { + if (event_id !== "clicked") return; + this.items.find((item) => item.id === id)?.activate?.(); + }, + EventGroup: (events: [number, string, GLib.Variant, number][]) => { + const id_errors: number[] = []; + + for (const [id, event_id] of events) { + const item = this.items.find((item) => item.id === id); + if (!item && id !== 0) { + id_errors.push(id); + continue; + } + if (event_id === "clicked") item?.activate?.(); + } + + return id_errors; + }, + AboutToShow: (_id: number) => true, + AboutToShowGroup: (ids: number[]) => { + return new GLib.Variant("(aiai)", [ids, []]); + }, + }; + } + + private root_properties(): Record { + return { + "children-display": new GLib.Variant("s", "submenu"), + }; + } + + private item_variant(item: TrayMenuItem) { + return new GLib.Variant("(ia{sv}av)", [item.id, item.properties, []]); + } + + private layout_variant(parent_id: number, recursion_depth: number) { + if (parent_id !== 0) { + const item = this.items.find((item) => item.id === parent_id); + + return item + ? this.item_variant(item) + : new GLib.Variant("(ia{sv}av)", [parent_id, {}, []]); + } + + const children = recursion_depth === 0 + ? [] + : this.items.map((item) => this.item_variant(item)); + + return new GLib.Variant("(ia{sv}av)", [ + 0, + this.root_properties(), + children, + ]); + } + + private swatch(style: Style) { + const cached = this.swatches.get(style); + if (cached) return cached; + + const hex = STYLE_COLORS.get(style) ?? STYLE_COLORS.get(Style.yellow)!; + const rgb = parseInt(hex.slice(1), 16); + const darken = (channel: number) => Math.round(channel * 0.6); + const border = (darken((rgb >> 16) & 0xff) << 16) | + (darken((rgb >> 8) & 0xff) << 8) | + darken(rgb & 0xff); + + const pixbuf = GdkPixbuf.Pixbuf.new( + GdkPixbuf.Colorspace.RGB, + true, + 8, + SWATCH_SIZE, + SWATCH_SIZE, + )!; + // pixel is RGBA as an unsigned 32-bit value; avoid << which yields + // negative numbers for bright colors + pixbuf.fill(border * 0x100 + 0xff); + + const inner_size = SWATCH_SIZE - 2 * SWATCH_BORDER; + const inner = GdkPixbuf.Pixbuf.new( + GdkPixbuf.Colorspace.RGB, + true, + 8, + inner_size, + inner_size, + )!; + inner.fill(rgb * 0x100 + 0xff); + inner.copy_area( + 0, + 0, + inner_size, + inner_size, + pixbuf, + SWATCH_BORDER, + SWATCH_BORDER, + ); + + const [success, buffer] = pixbuf.save_to_bufferv("png", [], []); + if (!success) return new GLib.Variant("ay", []); + + const variant = new GLib.Variant("ay", buffer); + this.swatches.set(style, variant); + return variant; + } + + private watch_notes() { + this.app.foreach_note((note) => { + if (this.connected_notes.has(note)) return; + this.connected_notes.add(note); + + // "modified" changes on any edit and also reorders the list; + // "style" changes the swatch color + note.connect("notify::modified", () => this.invalidate_menu()); + note.connect("notify::style", () => this.invalidate_menu()); + }); + } + + private invalidate_menu() { + if (this.rebuild_source !== null) return; + + this.rebuild_source = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 300, () => { + this.rebuild_source = null; + + const previous = new Map( + this.items.map((item) => [item.id, item.properties]), + ); + this.rebuild_menu(); + this.emit_menu_updates(previous); + + return GLib.SOURCE_REMOVE; + }); + } + + private emit_menu_updates( + previous: Map>, + ) { + const updated: [number, Record][] = []; + const removed: [number, string[]][] = []; + + for (const item of this.items) { + const old = previous.get(item.id); + // new items are picked up through LayoutUpdated + if (!old) continue; + + const changed: Record = {}; + for (const [name, value] of Object.entries(item.properties)) { + if (!old[name]?.equal(value)) changed[name] = value; + } + if (Object.keys(changed).length > 0) updated.push([item.id, changed]); + + const dropped = Object.keys(old).filter( + (name) => !(name in item.properties), + ); + if (dropped.length > 0) removed.push([item.id, dropped]); + } + + if (updated.length > 0 || removed.length > 0) { + this.menu_dbus_object?.emit_signal( + "ItemsPropertiesUpdated", + new GLib.Variant("(a(ia{sv})a(ias))", [updated, removed]), + ); + } + + this.menu_dbus_object?.emit_signal( + "LayoutUpdated", + new GLib.Variant("(ui)", [this.revision, 0]), + ); + } + + private note_id(uuid: string) { + let id = this.note_ids.get(uuid); + + if (id === undefined) { + id = this.next_note_id++; + this.note_ids.set(uuid, id); + } + + return id; + } + + private rebuild_menu() { + const items: TrayMenuItem[] = []; + + const notes = this.app.notes_array().sort(compare_notes_newest_first); + + const uuids = notes.map((note) => note.uuid); + const existing = new Set(uuids); + + for (const uuid of this.note_ids.keys()) { + if (!existing.has(uuid)) this.note_ids.delete(uuid); + } + + // hosts may not support moving items, so a reorder gets fresh ids + const known = new Set(this.note_order); + const kept = this.note_order.filter((uuid) => existing.has(uuid)); + const kept_now = uuids.filter((uuid) => known.has(uuid)); + if (kept.some((uuid, index) => uuid !== kept_now[index])) { + this.note_ids.clear(); + } + this.note_order = uuids; + + for (const note of notes) { + const uuid = note.uuid; + const title = note.title.trim(); + + items.push({ + id: this.note_id(uuid), + properties: { + label: new GLib.Variant( + "s", + title ? escape_label(title) : `(${_("Empty Note")})`, + ), + "icon-data": this.swatch(note.style), + }, + activate: () => this.app.show_note(uuid), + }); + } + + items.push({ + id: SEPARATOR_ID, + properties: { type: new GLib.Variant("s", "separator") }, + }); + + items.push({ + id: OPEN_ID, + properties: { + label: new GLib.Variant("s", escape_label(_("Open Sticky Notes"))), + "icon-name": new GLib.Variant("s", "edit-find-symbolic"), + }, + activate: () => this.app.search_notes(), + }); + + this.items = items; + this.revision++; + } +} diff --git a/src/util.ts b/src/util.ts index 66a36c2..2aac3c7 100644 --- a/src/util.ts +++ b/src/util.ts @@ -302,6 +302,10 @@ export class Note extends GObject.Object { } } +// newest first; the order shared by the all notes view and the tray menu +export const compare_notes_newest_first = (note1: Note, note2: Note) => + note2.modified.compare(note1.modified); + export const confirm_delete = (window: Gtk.Window, cb: () => void) => { if (SETTINGS.CONFIRM_DELETE) { const dialog = Adw.MessageDialog.new( From 556aa54ab034b1839a85ab2b14780d53c3d1ec6a Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Thu, 2 Jul 2026 14:21:53 +0200 Subject: [PATCH 2/2] Add a Nix flake for developing and running the app Adds a flake that exposes the application built from the working tree (nix run), a dev shell with the meson, ninja, yarn and GJS toolchain including a sticky-run helper that launches the app uninstalled through a GResource overlay (nix develop), and an install-appindicator helper that links the AppIndicator GNOME Shell extension into the user profile so the tray icon can be tested with and without a StatusNotifierWatcher. Also ignores the local meson and Nix build outputs. --- .gitignore | 6 +++- flake.lock | 27 +++++++++++++++ flake.nix | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.gitignore b/.gitignore index f96843d..ebc3c55 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,8 @@ node_modules/ # local flatpak build /repo -/.flatpak-builder/ \ No newline at end of file +/.flatpak-builder/ + +# local nix build +/build/ +/result \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..b84cc16 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1782723713, + "narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..cb370b1 --- /dev/null +++ b/flake.nix @@ -0,0 +1,96 @@ +{ + description = "Sticky Notes — GNOME sticky notes app (with tray icon)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems ( + system: f system nixpkgs.legacyPackages.${system} + ); + in + { + packages = forAllSystems (system: pkgs: { + # reuses the nixpkgs sticky-notes derivation (same version, same + # yarn.lock, so the yarn offline cache hash still applies) but builds + # from this source tree + default = pkgs.sticky-notes.overrideAttrs (old: { + version = "${old.version}-dev"; + src = self; + }); + }); + + apps = forAllSystems (system: pkgs: { + # nix run — build and launch the app + default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/com.vixalien.sticky"; + meta.description = "Run Sticky Notes built from this source tree"; + }; + + # nix run .#install-appindicator — link the AppIndicator GNOME Shell + # extension (a StatusNotifierWatcher host) into the user profile, so + # the tray icon can be tested with and without it + install-appindicator = { + type = "app"; + program = toString (pkgs.writeShellScript "install-appindicator" '' + set -eu + uuid=appindicatorsupport@rgcjonas.gmail.com + src=${pkgs.gnomeExtensions.appindicator}/share/gnome-shell/extensions/$uuid + dest="$HOME/.local/share/gnome-shell/extensions/$uuid" + mkdir -p "$(dirname "$dest")" + ln -sfT "$src" "$dest" + echo "AppIndicator extension linked into $dest" + echo + echo "GNOME Shell only picks up new extensions on login:" + echo "log out and back in, then run:" + echo + echo " gnome-extensions enable $uuid" + echo + echo "Toggle it off/on with gnome-extensions disable/enable (or the" + echo "Extensions app) to compare behavior; remove it again with:" + echo + echo " rm '$dest'" + ''); + meta.description = "Install the AppIndicator extension for the current user"; + }; + }); + + devShells = forAllSystems (system: pkgs: { + default = pkgs.mkShell { + # meson/ninja/yarn/gjs toolchain and GTK/libadwaita libraries, with + # the GI and GSettings setup hooks, exactly as the package build + inputsFrom = [ self.packages.${system}.default ]; + + packages = [ + pkgs.gjs # on PATH for running the app uninstalled + pkgs.nodejs # yarn typecheck + pkgs.libxml2 # xmllint, silences a gresource warning + + # build and run the app from the working tree: + # sticky-run [args...] + # GJS only registers the compiled-in source gresource for + # installed apps, so overlay the resource path onto the build dir + (pkgs.writeShellScriptBin "sticky-run" '' + set -eu + [ -d build ] || meson setup build + ninja -C build + cd build + export GSETTINGS_SCHEMA_DIR="$PWD/data" + export G_RESOURCE_OVERLAYS="/com/vixalien/sticky/js=$PWD/src" + exec gjs -m src/com.vixalien.sticky "$@" + '') + ]; + + shellHook = '' + echo "Sticky Notes dev shell" + echo " sticky-run build and run from the working tree" + echo " yarn typecheck check TypeScript (needs the gi-types submodule)" + ''; + }; + }); + }; +}