diff --git a/.eslintrc.yml b/.eslintrc.yml index 63295a2..5f0a132 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -22,6 +22,14 @@ plugins: rules: '@typescript-eslint/ban-ts-comment': - 'off' + + + '@typescript-eslint/no-unused-vars': + - error + # Vars use a suffix _ instead of a prefix because of file-scope private vars + - varsIgnorePattern: (^unused|_$) + argsIgnorePattern: ^(unused|_) + arrow-parens: - error - as-needed diff --git a/example/notification-center/config.js b/example/notification-center/config.js index 280e1ce..04817b8 100644 --- a/example/notification-center/config.js +++ b/example/notification-center/config.js @@ -1,31 +1,33 @@ -const { Window, Box, Label, EventBox } = ags.Widget; import { NotificationList, DNDSwitch, ClearButton, PopupList, } from './widgets.js'; +import Widget from 'resource:///com/github/Aylur/ags/widget.js'; +import App from 'resource:///com/github/Aylur/ags/app.js'; +import { execAsync, timeout } from 'resource:///com/github/Aylur/ags/utils.js'; -const Header = () => Box({ +const Header = () => Widget.Box({ className: 'header', children: [ - Label('Do Not Disturb'), + Widget.Label('Do Not Disturb'), DNDSwitch(), - Box({ hexpand: true }), + Widget.Box({ hexpand: true }), ClearButton(), ], }); -const NotificationCenter = () => Window({ +const NotificationCenter = () => Widget.Window({ name: 'notification-center', anchor: 'right top bottom', popup: true, focusable: true, - child: Box({ + child: Widget.Box({ children: [ - EventBox({ + Widget.EventBox({ hexpand: true, connections: [['button-press-event', () => - ags.App.closeWindow('notification-center')]] + App.closeWindow('notification-center')]] }), - Box({ + Widget.Box({ vertical: true, children: [ Header(), @@ -36,13 +38,13 @@ const NotificationCenter = () => Window({ }), }); -const NotificationsPopupWindow = () => Window({ +const NotificationsPopupWindow = () => Widget.Window({ name: 'popup-window', anchor: 'top', child: PopupList(), }); -ags.Utils.timeout(1000, () => ags.Utils.execAsync([ +timeout(500, () => execAsync([ 'notify-send', 'Notification Center example', 'To have the panel popup run "ags toggle-window notification-center"' + @@ -50,7 +52,7 @@ ags.Utils.timeout(1000, () => ags.Utils.execAsync([ ]).catch(console.error)); export default { - style: ags.App.configDir + '/style.css', + style: App.configDir + '/style.css', windows: [ NotificationsPopupWindow(), NotificationCenter(), diff --git a/example/notification-center/notification.js b/example/notification-center/notification.js index 2a0a5bf..545df08 100644 --- a/example/notification-center/notification.js +++ b/example/notification-center/notification.js @@ -1,10 +1,9 @@ -const { Notifications } = ags.Service; -const { lookUpIcon, timeout } = ags.Utils; -const { Box, Icon, Label, EventBox, Button } = ags.Widget; +import Widget from 'resource:///com/github/Aylur/ags/widget.js'; +import { lookUpIcon, timeout } from 'resource:///com/github/Aylur/ags/utils.js'; const NotificationIcon = ({ appEntry, appIcon, image }) => { if (image) { - return Box({ + return Widget.Box({ valign: 'start', hexpand: false, className: 'icon img', @@ -26,7 +25,7 @@ const NotificationIcon = ({ appEntry, appIcon, image }) => { if (lookUpIcon(appEntry)) icon = appEntry; - return Box({ + return Widget.Box({ valign: 'start', hexpand: false, className: 'icon', @@ -34,7 +33,7 @@ const NotificationIcon = ({ appEntry, appIcon, image }) => { min-width: 78px; min-height: 78px; `, - children: [Icon({ + children: [Widget.Icon({ icon, size: 58, halign: 'center', hexpand: true, valign: 'center', vexpand: true, @@ -42,40 +41,40 @@ const NotificationIcon = ({ appEntry, appIcon, image }) => { }); }; -export const Notification = ({ id, summary, body, actions, urgency, ...icon }) => EventBox({ - className: `notification ${urgency}`, - onPrimaryClick: () => Notifications.dismiss(id), +export const Notification = n => Widget.EventBox({ + className: `notification ${n.urgency}`, + onPrimaryClick: () => n.dismiss(), properties: [['hovered', false]], - onHover: w => { - if (w._hovered) + onHover: self => { + if (self._hovered) return; // if there are action buttons and they are hovered // EventBox onHoverLost will fire off immediately, // so to prevent this we delay it - timeout(300, () => w._hovered = true); + timeout(300, () => self._hovered = true); }, - onHoverLost: w => { - if (!w._hovered) + onHoverLost: self => { + if (!self._hovered) return; - w._hovered = false; - Notifications.dismiss(id); + self._hovered = false; + n.dismiss(); }, vexpand: false, - child: Box({ + child: Widget.Box({ vertical: true, children: [ - Box({ + Widget.Box({ children: [ - NotificationIcon(icon), - Box({ + NotificationIcon(n), + Widget.Box({ hexpand: true, vertical: true, children: [ - Box({ + Widget.Box({ children: [ - Label({ + Widget.Label({ className: 'title', xalign: 0, justification: 'left', @@ -83,37 +82,37 @@ export const Notification = ({ id, summary, body, actions, urgency, ...icon }) = maxWidthChars: 24, truncate: 'end', wrap: true, - label: summary, - useMarkup: summary.startsWith('<'), + label: n.summary, + useMarkup: true, }), - Button({ + Widget.Button({ className: 'close-button', valign: 'start', - child: Icon('window-close-symbolic'), - onClicked: () => Notifications.close(id), + child: Widget.Icon('window-close-symbolic'), + onClicked: n.close.bind(n), }), ], }), - Label({ + Widget.Label({ className: 'description', hexpand: true, useMarkup: true, xalign: 0, justification: 'left', - label: body, + label: n.body, wrap: true, }), ], }), ], }), - Box({ + Widget.Box({ className: 'actions', - children: actions.map(action => Button({ + children: n.actions.map(({ id, label }) => Button({ className: 'action-button', - onClicked: () => Notifications.invoke(id, action.id), + onClicked: () => n.invoke(id), hexpand: true, - child: Label(action.label), + child: Widget.Label(label), })), }), ], diff --git a/example/notification-center/widgets.js b/example/notification-center/widgets.js index 4a7c564..ecee080 100644 --- a/example/notification-center/widgets.js +++ b/example/notification-center/widgets.js @@ -1,40 +1,38 @@ import { Notification } from './notification.js'; -const { Gtk } = imports.gi; -const { Notifications } = ags.Service; -const { Scrollable, Box, Icon, Label, Widget, Button, Stack } = ags.Widget; +import Notifications from 'resource:///com/github/Aylur/ags/service/notifications.js'; +import Gtk from 'gi://Gtk'; +import Widget from 'resource:///com/github/Aylur/ags/widget.js'; -const List = () => Box({ +const List = () => Widget.Box({ vertical: true, vexpand: true, - connections: [[Notifications, box => { - box.children = Notifications.notifications + connections: [[Notifications, self => { + self.children = Notifications.notifications .reverse() - .map(n => Notification(n)); + .map(Notification); - box.visible = Notifications.notifications.length > 0; + self.visible = Notifications.notifications.length > 0; }]], }); -const Placeholder = () => Box({ +const Placeholder = () => Widget.Box({ className: 'placeholder', vertical: true, vexpand: true, valign: 'center', children: [ - Icon('notifications-disabled-symbolic'), - Label('Your inbox is empty'), + Widget.Icon('notifications-disabled-symbolic'), + Widget.Label('Your inbox is empty'), ], - connections: [ - [Notifications, box => { - box.visible = Notifications.notifications.length === 0; - }], + binds: [ + ['visible', Notifications, 'notifications', n => n.length === 0], ], }); -export const NotificationList = () => Scrollable({ +export const NotificationList = () => Widget.Scrollable({ hscroll: 'never', vscroll: 'automatic', - child: Box({ + child: Widget.Box({ className: 'list', vertical: true, children: [ @@ -44,22 +42,19 @@ export const NotificationList = () => Scrollable({ }), }); -export const ClearButton = () => Button({ - onClicked: Notifications.clear, - connections: [[Notifications, button => { - button.sensitive = Notifications.notifications.length > 0; - }]], - child: Box({ +export const ClearButton = () => Widget.Button({ + onClicked: () => Notifications.clear(), + binds: [ + ['sensitive', Notifications, 'notifications', n => n.length > 0], + ], + child: Widget.Box({ children: [ - Label('Clear'), - Stack({ - items: [ - ['true', Icon('user-trash-full-symbolic')], - ['false', Icon('user-trash-symbolic')], + Widget.Label('Clear'), + Widget.Icon({ + binds: [ + ['icon', Notifications, 'notifications', n => + `user-trash-${n.length > 0 ? 'full-' : ''}symbolic`], ], - connections: [[Notifications, stack => { - stack.shown = `${Notifications.notifications.length > 0}`; - }]], }), ], }), @@ -68,19 +63,15 @@ export const ClearButton = () => Button({ export const DNDSwitch = () => Widget({ type: Gtk.Switch, valign: 'center', - connections: [ - ['notify::active', ({ active }) => { - Notifications.dnd = active; - }], - ], + connections: [['notify::active', ({ active }) => { + Notifications.dnd = active; + }]], }); -export const PopupList = () => Box({ +export const PopupList = () => Widget.Box({ className: 'list', style: 'padding: 1px;', // so it shows up vertical: true, - connections: [[Notifications, box => { - box.children = Array.from(Notifications.popups.values()) - .map(n => Notification(n)); - }]], + binds: [['children', Notifications, 'popups', + popups => popups.map(Notification)]], }); diff --git a/example/simple-bar/config.js b/example/simple-bar/config.js index 3cd7141..05690dd 100644 --- a/example/simple-bar/config.js +++ b/example/simple-bar/config.js @@ -8,177 +8,166 @@ import SystemTray from 'resource:///com/github/Aylur/ags/service/systemtray.js'; import App from 'resource:///com/github/Aylur/ags/app.js'; import Widget from 'resource:///com/github/Aylur/ags/widget.js'; import { exec, execAsync } from 'resource:///com/github/Aylur/ags/utils.js'; -const { Box, Button, Stack, Label, Icon, CenterBox, Window, Slider, ProgressBar } = Widget; // widgets can be only assigned as a child in one container // so to make a reuseable widget, just make it a function // then you can use it by calling simply calling it -const Workspaces = () => Box({ +const Workspaces = () => Widget.Box({ className: 'workspaces', - connections: [[Hyprland, box => { + connections: [[Hyprland.active.workspace, self => { // generate an array [1..10] then make buttons from the index const arr = Array.from({ length: 10 }, (_, i) => i + 1); - box.children = arr.map(i => Button({ + self.children = arr.map(i => Widget.Button({ onClicked: () => execAsync(`hyprctl dispatch workspace ${i}`), - child: Label({ label: `${i}` }), + child: Widget.Label(`${i}`), className: Hyprland.active.workspace.id == i ? 'focused' : '', })); }]], }); -const ClientTitle = () => Label({ +const ClientTitle = () => Widget.Label({ className: 'client-title', - // an initial label value can be given but its pointless - // because callbacks from connections are run on construction - // so in this case this is redundant - label: Hyprland.active.client.title || '', - connections: [[Hyprland, label => { - label.label = Hyprland.active.client.title || ''; - }]], + binds: [ + ['label', Hyprland.active.client, 'title'], + ], }); -const Clock = () => Label({ +const Clock = () => Widget.Label({ className: 'clock', connections: [ // this is bad practice, since exec() will block the main event loop // in the case of a simple date its not really a problem - [1000, label => label.label = exec('date "+%H:%M:%S %b %e."')], + [1000, self => self.label = exec('date "+%H:%M:%S %b %e."')], // this is what you should do - [1000, label => execAsync(['date', '+%H:%M:%S %b %e.']) - .then(date => label.label = date).catch(console.error)], + [1000, self => execAsync(['date', '+%H:%M:%S %b %e.']) + .then(date => self.label = date).catch(console.error)], ], }); // we don't need dunst or any other notification daemon -// because ags has a notification daemon built in -const Notification = () => Box({ +// because the Notifications module is a notification daemon itself +const Notification = () => Widget.Box({ className: 'notification', children: [ - Icon({ + Widget.Icon({ icon: 'preferences-system-notifications-symbolic', connections: [ - [Notifications, icon => icon.visible = Notifications.popups.length > 0], + [Notifications, self => self.visible = Notifications.popups.length > 0], ], }), - Label({ - connections: [[Notifications, label => { - label.label = Notifications.popups[0]?.summary || ''; + Widget.Label({ + connections: [[Notifications, self => { + self.label = Notifications.popups[0]?.summary || ''; }]], }), ], }); -const Media = () => Button({ +const Media = () => Widget.Button({ className: 'media', onPrimaryClick: () => Mpris.getPlayer('')?.playPause(), onScrollUp: () => Mpris.getPlayer('')?.next(), onScrollDown: () => Mpris.getPlayer('')?.previous(), - child: Label({ - connections: [[Mpris, label => { + child: Widget.Label({ + connections: [[Mpris, self => { const mpris = Mpris.getPlayer(''); // mpris player can be undefined if (mpris) - label.label = `${mpris.trackArtists.join(', ')} - ${mpris.trackTitle}`; + self.label = `${mpris.trackArtists.join(', ')} - ${mpris.trackTitle}`; else - label.label = 'Nothing is playing'; + self.label = 'Nothing is playing'; }]], }), }); -const Volume = () => Box({ +const Volume = () => Widget.Box({ className: 'volume', style: 'min-width: 180px', children: [ - Stack({ + Widget.Stack({ items: [ // tuples of [string, Widget] - ['101', Icon('audio-volume-overamplified-symbolic')], - ['67', Icon('audio-volume-high-symbolic')], - ['34', Icon('audio-volume-medium-symbolic')], - ['1', Icon('audio-volume-low-symbolic')], - ['0', Icon('audio-volume-muted-symbolic')], + ['101', Widget.Icon('audio-volume-overamplified-symbolic')], + ['67', Widget.Icon('audio-volume-high-symbolic')], + ['34', Widget.Icon('audio-volume-medium-symbolic')], + ['1', Widget.Icon('audio-volume-low-symbolic')], + ['0', Widget.Icon('audio-volume-muted-symbolic')], ], - connections: [[Audio, stack => { + connections: [[Audio, self => { if (!Audio.speaker) return; if (Audio.speaker.isMuted) { - stack.shown = '0'; + self.shown = '0'; return; } const show = [101, 67, 34, 1, 0].find( threshold => threshold <= Audio.speaker.volume * 100); - stack.shown = `${show}`; + self.shown = `${show}`; }, 'speaker-changed']], }), - Slider({ + Widget.Slider({ hexpand: true, drawValue: false, onChange: ({ value }) => Audio.speaker.volume = value, - connections: [[Audio, slider => { - if (!Audio.speaker) - return; - - slider.value = Audio.speaker.volume; + connections: [[Audio, self => { + self.value = Audio.speaker?.volume || 0; }, 'speaker-changed']], }), ], }); -const BatteryLabel = () => Box({ +const BatteryLabel = () => Widget.Box({ className: 'battery', children: [ - Icon({ - connections: [[Battery, icon => { - icon.icon = `battery-level-${Math.floor(Battery.percent / 10) * 10}-symbolic`; + Widget.Icon({ + connections: [[Battery, self => { + self.icon = `battery-level-${Math.floor(Battery.percent / 10) * 10}-symbolic`; }]], }), - ProgressBar({ + Widget.ProgressBar({ valign: 'center', - connections: [[Battery, progress => { + connections: [[Battery, self => { if (Battery.percent < 0) return; - progress.fraction = Battery.percent / 100; + self.fraction = Battery.percent / 100; }]], }), ], }); -const SysTray = () => Box({ - connections: [[SystemTray, box => { - box.children = SystemTray.items.map(item => Button({ - child: Icon(), +const SysTray = () => Widget.Box({ + connections: [[SystemTray, self => { + self.children = SystemTray.items.map(item => Widget.Button({ + child: Widget.Icon({ binds: [['icon', item, 'icon']] }), onPrimaryClick: (_, event) => item.activate(event), onSecondaryClick: (_, event) => item.openMenu(event), - connections: [[item, button => { - button.child.icon = item.icon; - button.tooltipMarkup = item.tooltipMarkup; - }]], + binds: [['tooltip-markup', item, 'tooltip-markup']], })); }]], }); // layout of the bar -const Left = () => Box({ +const Left = () => Widget.Box({ children: [ Workspaces(), ClientTitle(), ], }); -const Center = () => Box({ +const Center = () => Widget.Box({ children: [ Media(), Notification(), ], }); -const Right = () => Box({ +const Right = () => Widget.Box({ halign: 'end', children: [ Volume(), @@ -188,13 +177,13 @@ const Right = () => Box({ ], }); -const Bar = ({ monitor } = {}) => Window({ +const Bar = ({ monitor } = {}) => Widget.Window({ name: `bar-${monitor}`, // name has to be unique className: 'bar', monitor, anchor: ['top', 'left', 'right'], exclusive: true, - child: CenterBox({ + child: Widget.CenterBox({ startWidget: Left(), centerWidget: Center(), endWidget: Right(), diff --git a/src/service/applications.ts b/src/service/applications.ts index afcf2ca..4383646 100644 --- a/src/service/applications.ts +++ b/src/service/applications.ts @@ -7,43 +7,36 @@ const CACHE_FILE = APPS_CACHE_DIR + '/apps_frequency.json'; class Application extends Service { static { - Service.register(this, { 'launched': [] }); + Service.register(this, { + 'launched': [], + }, { + 'app': ['jsobject'], + 'frequency': ['int'], + 'name': ['string'], + 'desktop': ['jsobject'], + 'description': ['jsobject'], + 'wm-class': ['jsobject'], + 'executable': ['string'], + 'icon-name': ['string'], + }); } - app: Gio.DesktopAppInfo; - frequency: number; - name: string; - desktop: string | null; - description: string | null; - wmClass: string | null; - executable: string; - iconName: string; - service: ApplicationsService; + _app: Gio.DesktopAppInfo; + _frequency: number; - constructor(app: Gio.DesktopAppInfo, service: ApplicationsService) { + get app() { return this._app; } + get frequency() { return this._frequency; } + get name() { return this._app.get_name(); } + get desktop() { return this._app.get_id(); } + get description() { return this._app.get_description(); } + get wm_class() { return this._app.get_startup_wm_class(); } + get executable() { return this._app.get_executable(); } + get icon_name() { return this._app.get_string('Icon'); } + + constructor(app: Gio.DesktopAppInfo, frequency: number) { super(); - this.service = service; - this.app = app; - this.name = app.get_name(); - this.desktop = app.get_id(); - this.executable = app.get_executable(); - this.description = app.get_description(); - this.iconName = this._iconName(app); - this.wmClass = app.get_startup_wm_class(); - this.frequency = this.desktop && service.frequents[this.desktop] || 0; - } - - private _iconName(app: Gio.DesktopAppInfo): string { - if (!app.get_icon()) - return ''; - - // @ts-expect-error - if (typeof app.get_icon()?.get_names !== 'function') - return ''; - - // @ts-expect-error - const name = app.get_icon()?.get_names()[0]; - return name || ''; + this._app = app; + this._frequency = frequency; } private _match(prop: string | null, search: string) { @@ -56,6 +49,10 @@ class Application extends Service { return prop?.toLowerCase().includes(search.toLowerCase()); } + getKey(key: string) { + return this._app.get_string(key); + } + match(term: string) { const { name, desktop, description, executable } = this; return this._match(name, term) || @@ -65,7 +62,7 @@ class Application extends Service { } launch() { - this.frequency++; + this._frequency++; this.app.launch([], null); this.emit('launched'); } @@ -73,8 +70,9 @@ class Application extends Service { class ApplicationsService extends Service { static { - Service.register(this, { - 'launched': ['string'], + Service.register(this, {}, { + 'list': ['jsobject'], + 'frequents': ['jsobject'], }); } @@ -101,7 +99,7 @@ class ApplicationsService extends Service { this._sync(); } - get list() { return [...this._list]; } + get list() { return this._list; } get frequents() { return this._frequents; } private _launched(id: string | null) { @@ -115,6 +113,8 @@ class ApplicationsService extends Service { ensureDirectory(APPS_CACHE_DIR); const json = JSON.stringify(this._frequents, null, 2); writeFile(json, CACHE_FILE).catch(logError); + this.notify('frequents'); + this.emit('changed'); } private _sync() { @@ -122,12 +122,13 @@ class ApplicationsService extends Service { .filter(app => app.should_show()) .map(app => Gio.DesktopAppInfo.new(app.get_id() || '')) .filter(app => app) - .map(app => new Application(app, this)); + .map(app => new Application(app, this.frequents[app.get_id() || ''] || 0)); this._list.forEach(app => app.connect('launched', () => { this._launched(app.desktop); })); + this.notify('list'); this.emit('changed'); } } diff --git a/src/service/audio.ts b/src/service/audio.ts index 5684903..248d695 100644 --- a/src/service/audio.ts +++ b/src/service/audio.ts @@ -4,17 +4,30 @@ import Gvc from 'gi://Gvc'; import App from '../app.js'; import { bulkConnect, bulkDisconnect } from '../utils.js'; +const _MIXER_CONTROL_STATE = { + [Gvc.MixerControlState.CLOSED]: 'closed', + [Gvc.MixerControlState.READY]: 'ready', + [Gvc.MixerControlState.CONNECTING]: 'connecting', + [Gvc.MixerControlState.FAILED]: 'failed', +}; + class Stream extends Service { static { Service.register(this, { 'closed': [], + }, { + 'description': ['string'], + 'is-muted': ['boolean'], + 'volume': ['float', 'rw'], + 'icon-name': ['string'], + 'id': ['int'], + 'state': ['string'], }); } private _stream: Gvc.MixerStream; private _ids: number[]; - - get stream() { return this._stream; } + private _oldVolume = 0; constructor(stream: Gvc.MixerStream) { super(); @@ -27,20 +40,28 @@ class Stream extends Service { 'icon-name', 'id', 'state', - ].map(prop => stream.connect( - `notify::${prop}`, () => this.emit('changed'), - )); - - this.emit('changed'); + ].map(prop => stream.connect(`notify::${prop}`, () => { + this.changed(prop); + })); } + get stream() { return this._stream; } get description() { return this._stream.description; } - get iconName() { return this._stream.icon_name; } + get icon_name() { return this._stream.icon_name; } get id() { return this._stream.id; } get name() { return this._stream.name; } + get state() { return _MIXER_CONTROL_STATE[this._stream.state]; } - set isMuted(muted) { this._stream.set_is_muted(muted); } - get isMuted() { return this._stream.is_muted; } + get is_muted() { return this.volume === 0; } + set is_muted(mute: boolean) { + if (mute) { + this._oldVolume = this.volume; + this.volume = 0; + } + else if (this.volume === 0) { + this.volume = this._oldVolume || 0.25; + } + } get volume() { const max = Audio.instance.control.get_vol_max_norm(); @@ -72,27 +93,32 @@ class AudioService extends Service { 'microphone-changed': [], 'stream-added': ['int'], 'stream-removed': ['int'], + }, { + 'apps': ['jsobject', 'rw'], + 'speakers': ['jsobject', 'rw'], + 'microphones': ['jsobject', 'rw'], + 'speaker': ['jsobject', 'rw'], + 'microphone': ['jsobject', 'rw'], }); } private _control: Gvc.MixerControl; private _streams: Map; + private _streamBindings: Map; private _speaker!: Stream; private _microphone!: Stream; private _speakerBinding!: number; private _microphoneBinding!: number; - get speaker() { return this._speaker; } - get microphone() { return this._microphone; } - - get control() { return this._control; } - constructor() { super(); + this._control = new Gvc.MixerControl({ name: `${pkg.name} mixer control`, }); + this._streams = new Map(); + this._streamBindings = new Map(); bulkConnect((this._control as unknown) as GObject.Object, [ ['default-sink-changed', (_c, id: number) => this._defaultChanged(id, 'speaker')], @@ -104,6 +130,26 @@ class AudioService extends Service { this._control.open(); } + get control() { return this._control; } + + get speaker() { return this._speaker; } + set speaker(stream: Stream) { + this._control.set_default_sink(stream.stream); + } + + get microphone() { return this._microphone; } + set microphone(stream: Stream) { + this._control.set_default_source(stream.stream); + } + + get microphones() { return this._getStreams(Gvc.MixerSource); } + get speakers() { return this._getStreams(Gvc.MixerSink); } + get apps() { return this._getStreams(Gvc.MixerSinkInput); } + + getStream(id: number) { + return this._streams.get(id); + } + private _defaultChanged(id: number, type: 'speaker' | 'microphone') { if (this[`_${type}`]) this[`_${type}`].disconnect(this[`_${type}Binding`]); @@ -112,49 +158,61 @@ class AudioService extends Service { if (!stream) return; - this[`_${type}Binding`] = stream.connect( - 'changed', - () => this.emit(`${type}-changed`), - ); + this[`_${type}Binding`] = stream.connect('changed', () => this.emit(`${type}-changed`)); this[`_${type}`] = stream; + this.changed(type); this.emit(`${type}-changed`); - this.emit('changed'); } private _streamAdded(_c: Gvc.MixerControl, id: number) { if (this._streams.has(id)) return; - const stream = this._control.lookup_stream_id(id); + const gvcstream = this._control.lookup_stream_id(id); + const stream = new Stream(gvcstream); + const binding = stream.connect('changed', () => this.emit('changed')); + + this._streams.set(id, stream); + this._streamBindings.set(id, binding); + + if (gvcstream instanceof Gvc.MixerSource) + this.notify('microphones'); + + if (gvcstream instanceof Gvc.MixerSink) + this.notify('speakers'); + + if (gvcstream instanceof Gvc.MixerSinkInput) + this.notify('apps'); - this._streams.set(id, new Stream(stream)); - this.emit('changed'); this.emit('stream-added', id); + this.emit('changed'); } private _streamRemoved(_c: Gvc.MixerControl, id: number) { - if (!this._streams.has(id)) + const stream = this._streams.get(id); + if (!stream) return; - this._streams.get(id)?.close(); + stream.disconnect(this._streamBindings.get(id) as number); + stream.close(); + this._streams.delete(id); - this.emit('changed'); + this._streamBindings.delete(id); this.emit('stream-removed', id); + + if (stream.stream instanceof Gvc.MixerSource) + this.notify('microphones'); + + if (stream.stream instanceof Gvc.MixerSink) + this.notify('speakers'); + + if (stream.stream instanceof Gvc.MixerSinkInput) + this.notify('apps'); + + this.emit('changed'); } - setSpeaker(stream: Stream) { - this._control.set_default_sink(stream.stream); - } - - setMicrophone(stream: Stream) { - this._control.set_default_source(stream.stream); - } - - getStream(id: number) { - return this._streams.get(id); - } - - getStreams(filter: { new(): Gvc.MixerStream }) { + private _getStreams(filter: { new(): Gvc.MixerStream }) { const list = []; for (const [, stream] of this._streams) { if (stream.stream instanceof filter) @@ -174,13 +232,13 @@ export default class Audio { static getStream(id: number) { return Audio.instance.getStream(id); } - static get microphones() { return Audio.instance.getStreams(Gvc.MixerSource); } - static get speakers() { return Audio.instance.getStreams(Gvc.MixerSink); } - static get apps() { return Audio.instance.getStreams(Gvc.MixerSinkInput); } + static get microphones() { return Audio.instance.microphones; } + static get speakers() { return Audio.instance.speakers; } + static get apps() { return Audio.instance.apps; } static get microphone() { return Audio.instance.microphone; } - static set microphone(stream: Stream) { Audio.instance.setMicrophone(stream); } + static set microphone(stream: Stream) { Audio.instance.microphone = stream; } static get speaker() { return Audio.instance.speaker; } - static set speaker(stream: Stream) { Audio.instance.setSpeaker(stream); } + static set speaker(stream: Stream) { Audio.instance.speaker = stream; } } diff --git a/src/service/battery.ts b/src/service/battery.ts index 60aacc9..6bc17b8 100644 --- a/src/service/battery.ts +++ b/src/service/battery.ts @@ -14,15 +14,31 @@ const DeviceState = { }; class BatteryService extends Service { - static { Service.register(this); } + static { + Service.register(this, { + 'closed': [], + }, { + 'available': ['boolean'], + 'percent': ['int'], + 'charging': ['boolean'], + 'charged': ['boolean'], + 'icon-name': ['string'], + }); + } - available = false; - percent = -1; - charging = false; - charged = false; - iconName = 'battery-missing-symbolic'; private _proxy: BatteryProxy; + private _available = false; + private _percent = -1; + private _charging = false; + private _charged = false; + private _iconName = 'battery-missing-symbolic'; + + get available() { return this._available; } + get percent() { return this._percent; } + get charging() { return this._charging; } + get charged() { return this._charged; } + get icon_name() { return this._iconName; } constructor() { super(); @@ -39,26 +55,27 @@ class BatteryService extends Service { private _sync() { if (!this._proxy.IsPresent) - return; + return this.updateProperty('available', false); + const charging = this._proxy.State === DeviceState.CHARGING; const percent = this._proxy.Percentage; const charged = this._proxy.State === DeviceState.FULLY_CHARGED || (this._proxy.State === DeviceState.CHARGING && percent === 100); + const level = Math.floor(percent / 10) * 10; const state = this._proxy.State === DeviceState.CHARGING ? '-charging' : ''; - const level = Math.floor(percent / 10) * 10; - this.iconName = charged + const iconName = charged ? 'battery-level-100-charged-symbolic' : `battery-level-${level}${state}-symbolic`; - this.charging = this._proxy.State === DeviceState.CHARGING; - this.percent = percent; - this.charged = charged; - this.available = true; - + this.updateProperty('available', true); + this.updateProperty('icon-name', iconName); + this.updateProperty('percent', percent); + this.updateProperty('charging', charging); + this.updateProperty('charged', charged); this.emit('changed'); } } @@ -75,5 +92,8 @@ export default class Battery { static get percent() { return Battery.instance.percent; } static get charging() { return Battery.instance.charging; } static get charged() { return Battery.instance.charged; } - static get iconName() { return Battery.instance.iconName; } + + static get iconName() { return Battery.instance.icon_name; } + static get icon_name() { return Battery.instance.icon_name; } + static get ['icon-name']() { return Battery.instance.icon_name; } } diff --git a/src/service/bluetooth.ts b/src/service/bluetooth.ts index 6fd8a19..aa8c024 100644 --- a/src/service/bluetooth.ts +++ b/src/service/bluetooth.ts @@ -1,25 +1,44 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import GObject from 'gi://GObject'; import Service from './service.js'; +import Gio from 'gi://Gio'; import { bulkConnect, bulkDisconnect } from '../utils.js'; imports.gi.versions.GnomeBluetooth = '3.0'; const { GnomeBluetooth } = imports.gi; -class Device extends GObject.Object { +const _ADAPTER_STATE = { + [GnomeBluetooth.AdapterState.ABSENT]: 'absent', + [GnomeBluetooth.AdapterState.ON]: 'on', + [GnomeBluetooth.AdapterState.TURNING_ON]: 'turning-on', + [GnomeBluetooth.AdapterState.TURNING_OFF]: 'turning-off', + [GnomeBluetooth.AdapterState.OFF]: 'off', +}; + +class BluetoothDevice extends Service { static { - GObject.registerClass({ - Signals: { 'changed': {} }, - }, this); + Service.register(this, {}, { + 'address': ['string'], + 'alias': ['string'], + 'battery-level': ['int'], + 'battery-percentage': ['int'], + 'connected': ['boolean'], + 'icon-name': ['string'], + 'name': ['string'], + 'paired': ['boolean'], + 'trusted': ['boolean'], + 'type': ['string'], + 'connecting': ['boolean'], + }); } - private _device: any; + // @ts-expect-error + private _device: GnomeBluetooth.Device; private _ids: number[]; private _connecting = false; get device() { return this._device; } - constructor(device: any) { + // @ts-expect-error + constructor(device: GnomeBluetooth.Device) { super(); this._device = device; @@ -29,13 +48,16 @@ class Device extends GObject.Object { 'battery-level', 'battery-percentage', 'connected', - 'icon', 'name', 'paired', 'trusted', - ].map(prop => device.connect( - `notify::${prop}`, () => this.emit('changed'), - )); + ].map(prop => device.connect(`notify::${prop}`, () => { + this.changed(prop); + })); + + this._ids.push(device.connect('notify::icon', () => { + this.changed('icon-name'); + })); } close() { @@ -44,32 +66,39 @@ class Device extends GObject.Object { get address() { return this._device.address; } get alias() { return this._device.alias; } - get connecting() { return this._connecting; } + get battery_level() { return this._device.battery_level; } + get battery_percentage() { return this._device.battery_percentage; } get connected() { return this._device.connected; } - get batteryLevel() { return this._device.battery_level; } - get batteryPercentage() { return this._device.battery_percentage; } - get iconName() { return this._device.icon; } + get icon_name() { return this._device.icon; } get name() { return this._device.name; } get paired() { return this._device.paired; } get trusted() { return this._device.trusted; } get type() { return GnomeBluetooth.type_to_string(this._device.type); } + get connecting() { return this._connecting || false; } setConnection(connect: boolean) { this._connecting = true; Bluetooth.instance.connectDevice(this, connect, () => { this._connecting = false; - this.emit('changed'); + this.changed('connecting'); }); - this.emit('changed'); + this.changed('connecting'); } } class BluetoothService extends Service { - static { Service.register(this); } + static { + Service.register(this, {}, { + 'devices': ['jsobject'], + 'connected-devices': ['jsobject'], + 'enabled': ['boolean', 'rw'], + 'state': ['string'], + }); + } // @ts-expect-error private _client: GnomeBluetooth.Client; - private _devices: Map; + private _devices: Map; constructor() { super(); @@ -77,16 +106,17 @@ class BluetoothService extends Service { this._devices = new Map(); this._client = new GnomeBluetooth.Client(); bulkConnect(this._client, [ - ['notify::default-adapter-state', () => this.emit('changed')], ['device-added', this._deviceAdded.bind(this)], ['device-removed', this._deviceRemoved.bind(this)], + ['notify::default-adapter-state', () => this.changed('state')], + ['notify::default-adapter-powered', () => this.changed('enabled')], ]); + this._getDevices().forEach(device => this._deviceAdded(this, device)); } toggle() { - this._client.default_adapter_powered = - !this._client.default_adapter_powered; + this._client.default_adapter_powered = !this._client.default_adapter_powered; } private _getDevices() { @@ -103,27 +133,31 @@ class BluetoothService extends Service { return devices; } - private _deviceAdded(_c: unknown, device: any) { + // @ts-expect-error + private _deviceAdded(_c: unknown, device: GnomeBluetooth.Device) { if (this._devices.has(device.address)) return; - const d = new Device(device); + const d = new BluetoothDevice(device); d.connect('changed', () => this.emit('changed')); this._devices.set(device.address, d); - this.emit('changed'); + this.changed('devices'); } - private _deviceRemoved(_c: unknown, device: Device) { + // @ts-expect-error + private _deviceRemoved(_c: unknown, device: GnomeBluetooth.Device) { if (!this._devices.has(device.address)) return; this._devices.get(device.address)?.close(); this._devices.delete(device.address); + this.notify('devices'); + this.notify('connected-devices'); this.emit('changed'); } connectDevice( - device: Device, + device: BluetoothDevice, connect: boolean, callback: (s: boolean) => void, ) { @@ -131,10 +165,12 @@ class BluetoothService extends Service { device.device.get_object_path(), connect, null, - (client: any, res: any) => { + // @ts-expect-error + (client: GnomeBluetooth.Client, res: Gio.AsyncResult) => { try { const s = client.connect_service_finish(res); callback(s); + this.changed('connected-devices'); } catch (error) { logError(error as Error); callback(false); @@ -148,18 +184,10 @@ class BluetoothService extends Service { set enabled(v) { this._client.default_adapter_powered = v; } get enabled() { return this.state === 'on' || this.state === 'turning-on'; } - get state() { - switch (this._client.default_adapter_state) { - case GnomeBluetooth.AdapterState.ON: return 'on'; - case GnomeBluetooth.AdapterState.TURNING_ONON: return 'turning-on'; - case GnomeBluetooth.AdapterState.OFF: return 'off'; - case GnomeBluetooth.AdapterState.TURNING_OFF: return 'turning-off'; - default: return 'absent'; - } - } + get state() { return _ADAPTER_STATE[this._client.default_adapter_state]; } get devices() { return Array.from(this._devices.values()); } - get connectedDevices() { + get connected_devices() { const list = []; for (const [, device] of this._devices) { if (device.connected) @@ -179,8 +207,11 @@ export default class Bluetooth { static getDevice(address: string) { return Bluetooth.instance.getDevice(address); } - static get devices() { return Bluetooth.instance.devices; } - static get connectedDevices() { return Bluetooth.instance.connectedDevices; } static get enabled() { return Bluetooth.instance.enabled; } static set enabled(enable: boolean) { Bluetooth.instance.enabled = enable; } + static get state() { return Bluetooth.instance.state; } + static get devices() { return Bluetooth.instance.devices; } + static get connectedDevices() { return Bluetooth.instance.connected_devices; } + static get connected_devices() { return Bluetooth.instance.connected_devices; } + static get ['connected-devices']() { return Bluetooth.instance.connected_devices; } } diff --git a/src/service/hyprland.ts b/src/service/hyprland.ts index 5e273d1..6cbc952 100644 --- a/src/service/hyprland.ts +++ b/src/service/hyprland.ts @@ -5,17 +5,75 @@ import { execAsync } from '../utils.js'; const HIS = GLib.getenv('HYPRLAND_INSTANCE_SIGNATURE'); -interface Active { - client: { - address: string - title: string - class: string +class Active extends Service { + updateProperty(prop: string, value: unknown): void { + super.updateProperty(prop, value); + this.emit('changed'); } - monitor: string - workspace: { - id: number - name: string +} + +class ActiveClient extends Active { + static { + Service.register(this, {}, { + 'address': ['string'], + 'title': ['string'], + 'class': ['string'], + }); } + + private _address = ''; + private _title = ''; + private _class = ''; + + get address() { return this._address; } + get title() { return this._title; } + get class() { return this._class; } +} + +class ActiveWorkspace extends Active { + static { + Service.register(this, {}, { + 'id': ['int'], + 'name': ['string'], + }); + } + + private _id = 1; + private _name = ''; + + get id() { return this._id; } + get name() { return this._name; } +} + +class Actives extends Service { + static { + Service.register(this, {}, { + 'client': ['jsobject'], + 'monitor': ['string'], + 'workspace': ['jsobject'], + }); + } + + constructor() { + super(); + + [this._client, this._workspace].forEach(s => + s.connect('changed', () => this.emit('changed'))); + + ['id', 'name'].forEach(attr => + this._workspace.connect(`notify::${attr}`, () => this.changed('workspace'))); + + ['address', 'title', 'class'].forEach(attr => + this._client.connect(`notify::${attr}`, () => this.changed('client'))); + } + + private _client = new ActiveClient(); + private _monitor = ''; + private _workspace = new ActiveWorkspace(); + + get client() { return this._client; } + get monitor() { return this._monitor; } + get workspace() { return this._workspace; } } class HyprlandService extends Service { @@ -26,37 +84,39 @@ class HyprlandService extends Service { 'keyboard-layout': ['string', 'string'], 'monitor-added': ['string'], 'monitor-removed': ['string'], + 'client-added': ['string'], + 'client-removed': ['string'], + 'workspace-added': ['string'], + 'workspace-removed': ['string'], + }, { + 'active': ['jsobject'], + 'monitors': ['jsobject'], + 'workspaces': ['jsobject'], + 'clients': ['jsobject'], }); } - private _active: Active; + private _active: Actives; private _monitors: Map; private _workspaces: Map; private _clients: Map; private _decoder = new TextDecoder(); get active() { return this._active; } - get monitors() { return this._monitors; } - get workspaces() { return this._workspaces; } - get clients() { return this._clients; } + get monitors() { return Array.from(this._monitors.values()); } + get workspaces() { return Array.from(this._workspaces.values()); } + get clients() { return Array.from(this._clients.values()); } + + getMonitor(id: number) { return this._monitors.get(id); } + getWorkspace(id: number) { return this._workspaces.get(id); } + getClient(address: string) { return this._clients.get(address); } constructor() { if (!HIS) console.error('Hyprland is not running'); super(); - this._active = { - client: { - address: '', - title: '', - class: '', - }, - monitor: '', - workspace: { - id: 0, - name: '', - }, - }; + this._active = new Actives(); this._monitors = new Map(); this._workspaces = new Map(); this._clients = new Map(); @@ -72,21 +132,23 @@ class HyprlandService extends Service { }), null) .get_input_stream(), })); + + this._active.connect('changed', () => this.emit('changed')); + ['monitor', 'workspace', 'client'].forEach(active => + this._active.connect(`notify::${active}`, () => this.changed('active'))); } private _watchSocket(stream: Gio.DataInputStream) { - stream.read_line_async( - 0, null, - (stream, result) => { - if (!stream) { - console.error('Error reading Hyprland socket'); - return; - } + stream.read_line_async(0, null, (stream, result) => { + if (!stream) { + console.error('Error reading Hyprland socket'); + return; + } - const [line] = stream.read_line_finish(result); - this._onEvent(this._decoder.decode(line)); - this._watchSocket(stream); - }); + const [line] = stream.read_line_finish(result); + this._onEvent(this._decoder.decode(line)); + this._watchSocket(stream); + }); } private async _syncMonitors() { @@ -105,10 +167,12 @@ class HyprlandService extends Service { json.forEach(monitor => { this._monitors.set(monitor.id, monitor); if (monitor.focused) { - this._active.monitor = monitor.name; - this._active.workspace = monitor.activeWorkspace; + this._active.updateProperty('monitor', monitor.name); + this._active.workspace.updateProperty('id', monitor.activeWorkspace.id); + this._active.workspace.updateProperty('name', monitor.activeWorkspace.name); } }); + this.notify('monitors'); } catch (error) { logError(error as Error); } @@ -122,6 +186,7 @@ class HyprlandService extends Service { json.forEach(ws => { this._workspaces.set(ws.id, ws); }); + this.notify('workspaces'); } catch (error) { logError(error as Error); } @@ -135,6 +200,7 @@ class HyprlandService extends Service { json.forEach(client => { this._clients.set(client.address, client); }); + this.notify('clients'); } catch (error) { logError(error as Error); } @@ -165,11 +231,21 @@ class HyprlandService extends Service { break; case 'createworkspace': + await this._syncWorkspaces(); + this.emit('workspace-added', argv[0]); + break; + case 'destroyworkspace': await this._syncWorkspaces(); + this.emit('workspace-removed', argv[0]); break; case 'openwindow': + await this._syncClients(); + await this._syncWorkspaces(); + this.emit('client-added', '0x' + argv[0]); + break; + case 'movewindow': case 'windowtitle': await this._syncClients(); @@ -182,22 +258,21 @@ class HyprlandService extends Service { break; case 'activewindow': - this._active.client.class = argv[0]; - this._active.client.title = argv.slice(1).join(','); + this._active.client.updateProperty('class', argv[0]); + this._active.client.updateProperty('title', argv.slice(1).join(',')); break; case 'activewindowv2': - this._active.client.address = '0x' + argv[0]; + this._active.client.updateProperty('address', '0x' + argv[0]); break; case 'closewindow': - this._active.client = { - class: '', - title: '', - address: '', - }; + this._active.client.updateProperty('class', ''); + this._active.client.updateProperty('title', ''); + this._active.client.updateProperty('address', ''); await this._syncClients(); await this._syncWorkspaces(); + this.emit('client-removed', '0x' + argv[0]); break; case 'urgent': @@ -238,16 +313,19 @@ export default class Hyprland { return Hyprland._instance; } - static getMonitor(id: number) { return Hyprland.instance.monitors.get(id); } - static getWorkspace(id: number) { return Hyprland.instance.workspaces.get(id); } - static getClient(address: string) { return Hyprland.instance.clients.get(address); } + static getMonitor(id: number) { return Hyprland.instance.getMonitor(id); } + static getWorkspace(id: number) { return Hyprland.instance.getWorkspace(id); } + static getClient(address: string) { return Hyprland.instance.getClient(address); } - static get monitors() { return Array.from(Hyprland.instance.monitors.values()); } - static get workspaces() { return Array.from(Hyprland.instance.workspaces.values()); } - static get clients() { return Array.from(Hyprland.instance.clients.values()); } + static get monitors() { return Hyprland.instance.monitors; } + static get workspaces() { return Hyprland.instance.workspaces; } + static get clients() { return Hyprland.instance.clients; } static get active() { return Hyprland.instance.active; } static HyprctlGet(cmd: string): unknown | object { + console.error('Hyprland.HyprctlGet is DEPRECATED' + + "use JSON.parse(Utils.exec('hyprctl -j')) instead"); + const [success, out, err] = GLib.spawn_command_line_sync(`hyprctl -j ${cmd}`); diff --git a/src/service/mpris.ts b/src/service/mpris.ts index d918ac3..757b08a 100644 --- a/src/service/mpris.ts +++ b/src/service/mpris.ts @@ -1,6 +1,5 @@ import GLib from 'gi://GLib'; import Gio from 'gi://Gio'; -import GObject from 'gi://GObject'; import Service from './service.js'; import { ensureDirectory, timeout } from '../utils.js'; import { CACHE_DIR } from '../utils.js'; @@ -27,34 +26,68 @@ type MprisMetadata = { [key: string]: unknown } -class MprisPlayer extends GObject.Object { +class MprisPlayer extends Service { static { - GObject.registerClass({ - Signals: { - 'changed': {}, - 'closed': {}, - 'position': { param_types: [GObject.TYPE_INT] }, - }, - }, this); + Service.register(this, { + 'closed': [], + 'position': ['int'], + }, { + 'bus-name': ['string'], + 'name': ['string'], + 'entry': ['string'], + 'identity': ['string'], + 'trackid': ['string'], + 'track-artists': ['jsobject'], + 'track-title': ['string'], + 'track-cover-url': ['string'], + 'cover-path': ['string'], + 'play-back-status': ['string'], + 'can-go-next': ['boolean'], + 'can-go-prev': ['boolean'], + 'can-play': ['boolean'], + 'shuffle-status': ['jsobject'], + 'loop-status': ['jsobject'], + 'length': ['int'], + 'position': ['float', 'rw'], + 'volume': ['float', 'rw'], + }); } - busName: string; - name: string; - entry!: string; - identity!: string; + get bus_name() { return this._busName; } + get name() { return this._name; } + get entry() { return this._entry; } + get identity() { return this._identity; } - trackid!: string; - trackArtists!: string[]; - trackTitle!: string; - trackCoverUrl!: string; - coverPath!: string; - playBackStatus!: PlaybackStatus; - canGoNext!: boolean; - canGoPrev!: boolean; - canPlay!: boolean; - shuffleStatus!: boolean | null; - loopStatus!: LoopStatus | null; - length!: number; + get trackid() { return this._trackid; } + get track_artists() { return this._trackArtists; } + get track_title() { return this._trackTitle; } + get track_cover_url() { return this._trackCoverUrl; } + get cover_path() { return this._coverPath; } + get play_back_status() { return this._playBackStatus; } + get can_go_next() { return this._canGoNext; } + get can_go_prev() { return this._canGoPrev; } + get can_play() { return this._canPlay; } + get shuffle_status() { return this._shuffleStatus; } + get loop_status() { return this._loopStatus; } + get length() { return this._length; } + + private _busName: string; + private _name: string; + private _entry!: string; + private _identity!: string; + + private _trackid!: string; + private _trackArtists!: string[]; + private _trackTitle!: string; + private _trackCoverUrl!: string; + private _coverPath!: string; + private _playBackStatus!: PlaybackStatus; + private _canGoNext!: boolean; + private _canGoPrev!: boolean; + private _canPlay!: boolean; + private _shuffleStatus!: boolean | null; + private _loopStatus!: LoopStatus | null; + private _length!: number; private _binding: { mpris: number, player: number }; private _mprisProxy: MprisProxy; @@ -63,8 +96,8 @@ class MprisPlayer extends GObject.Object { constructor(busName: string) { super(); - this.busName = busName; - this.name = busName.substring(23).split('.')[0]; + this._busName = busName; + this._name = busName.substring(23).split('.')[0]; this._binding = { mpris: 0, player: 0 }; this._mprisProxy = new MprisProxy( @@ -95,8 +128,8 @@ class MprisPlayer extends GObject.Object { this.close(); }); - this.identity = this._mprisProxy.Identity; - this.entry = this._mprisProxy.DesktopEntry; + this._identity = this._mprisProxy.Identity; + this._entry = this._mprisProxy.DesktopEntry; if (!this._mprisProxy.g_name_owner) this.close(); } @@ -131,51 +164,48 @@ class MprisPlayer extends GObject.Object { ? -1 : Number.parseInt(`${length}`.substring(0, 3)); - this.shuffleStatus = this._playerProxy.Shuffle; - this.loopStatus = this._playerProxy.LoopStatus as LoopStatus; - this.canGoNext = this._playerProxy.CanGoNext; - this.canGoPrev = this._playerProxy.CanGoPrevious; - this.canPlay = this._playerProxy.CanPlay; - this.playBackStatus = - this._playerProxy.PlaybackStatus as PlaybackStatus; - - this.trackid = metadata['mpris:trackid']; - this.trackArtists = trackArtists; - this.trackTitle = trackTitle; - this.trackCoverUrl = trackCoverUrl; - this.length = length; + this.updateProperty('shuffle-status', this._playerProxy.Shuffle); + this.updateProperty('loop-status', this._playerProxy.LoopStatus); + this.updateProperty('can-go-next', this._playerProxy.CanGoNext); + this.updateProperty('can-go-prev', this._playerProxy.CanGoPrevious); + this.updateProperty('can-play', this._playerProxy.CanPlay); + this.updateProperty('play-back-status', this._playerProxy.PlaybackStatus); + this.updateProperty('trackid', metadata['mpris:trackid']); + this.updateProperty('track-artists', trackArtists); + this.updateProperty('track-title', trackTitle); + this.updateProperty('track-cover-url', trackCoverUrl); + this.updateProperty('length', length); this._cacheCoverArt(); this.emit('changed'); } private _cacheCoverArt() { - this.coverPath = MEDIA_CACHE_PATH + '/' + - `${this.trackArtists.join(', ')}-${this.trackTitle}` + this._coverPath = MEDIA_CACHE_PATH + '/' + + `${this._trackArtists.join(', ')}-${this._trackTitle}` .replace(/[\,\*\?\"\<\>\|\#\:\?\/\'\(\)]/g, ''); - if (this.coverPath.length > 255) - this.coverPath = this.coverPath.substring(0, 255); + if (this._coverPath.length > 255) + this._coverPath = this._coverPath.substring(0, 255); - const { trackCoverUrl, coverPath } = this; - if (trackCoverUrl === '' || coverPath === '') + if (this._trackCoverUrl === '' || this._coverPath === '') return; - if (GLib.file_test(coverPath, GLib.FileTest.EXISTS)) + if (GLib.file_test(this._coverPath, GLib.FileTest.EXISTS)) return; ensureDirectory(MEDIA_CACHE_PATH); - Gio.File.new_for_uri(trackCoverUrl).copy_async( - Gio.File.new_for_path(coverPath), + Gio.File.new_for_uri(this._trackCoverUrl).copy_async( + Gio.File.new_for_path(this._coverPath), Gio.FileCopyFlags.OVERWRITE, GLib.PRIORITY_DEFAULT, // @ts-expect-error null, null, (source, result) => { try { source.copy_finish(result); - this.emit('changed'); + this.changed('cover-path'); } catch (e) { - logError(e as Error, `failed to cache ${coverPath}`); + logError(e as Error, `failed to cache ${this._coverPath}`); } }, ); @@ -198,7 +228,7 @@ class MprisPlayer extends GObject.Object { Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, null, - this.busName, + this._busName, '/org/mpris/MediaPlayer2', 'org.mpris.MediaPlayer2.Player', null, @@ -211,6 +241,7 @@ class MprisPlayer extends GObject.Object { set position(time: number) { const micro = Math.floor(time * 1_000_000); this._playerProxy.SetPositionAsync(this.trackid, micro); + this.notify('position'); this.emit('position', time); } @@ -246,6 +277,8 @@ class MprisService extends Service { 'player-changed': ['string'], 'player-closed': ['string'], 'player-added': ['string'], + }, { + 'players': ['jsobject'], }); } @@ -274,7 +307,7 @@ class MprisService extends Service { player.connect('closed', () => { this._players.delete(busName); this.emit('player-closed', busName); - this.emit('changed'); + this.changed('players'); }); player.connect('changed', () => { @@ -284,6 +317,7 @@ class MprisService extends Service { this._players.set(busName, player); this.emit('player-added', busName); + this.changed('players'); } private _onProxyReady() { @@ -328,9 +362,6 @@ export default class Mpris { return Mpris._instance; } - static getPlayer(name: string) { - return Mpris.instance.getPlayer(name); - } - + static getPlayer(name: string) { return Mpris.instance.getPlayer(name); } static get players() { return Mpris.instance.players; } } diff --git a/src/service/network.ts b/src/service/network.ts index e917b3a..f2c6824 100644 --- a/src/service/network.ts +++ b/src/service/network.ts @@ -3,7 +3,7 @@ import GObject from 'gi://GObject'; import Service from './service.js'; import { bulkConnect } from '../utils.js'; -const INTERNET = (device: NM.Device) => { +const _INTERNET = (device: NM.Device) => { switch (device?.active_connection?.state) { case NM.ActiveConnectionState.ACTIVATED: return 'connected'; case NM.ActiveConnectionState.ACTIVATING: return 'connecting'; @@ -13,7 +13,7 @@ const INTERNET = (device: NM.Device) => { } }; -const DEVICE_STATE = (device: NM.Device) => { +const _DEVICE_STATE = (device: NM.Device) => { switch (device?.state) { case NM.DeviceState.UNMANAGED: return 'unmanaged'; case NM.DeviceState.UNAVAILABLE: return 'unavailable'; @@ -31,7 +31,7 @@ const DEVICE_STATE = (device: NM.Device) => { } }; -const CONNECTIVITY_STATE = (client: NM.Client) => { +const _CONNECTIVITY_STATE = (client: NM.Client) => { switch (client.connectivity) { case NM.ConnectivityState.NONE: return 'none'; case NM.ConnectivityState.PORTAL: return 'portal'; @@ -41,7 +41,7 @@ const CONNECTIVITY_STATE = (client: NM.Client) => { } }; -const STRENGHT_ICONS = [ +const _STRENGTH_ICONS = [ { value: 80, icon: 'network-wireless-signal-excellent-symbolic' }, { value: 60, icon: 'network-wireless-signal-good-symbolic' }, { value: 40, icon: 'network-wireless-signal-ok-symbolic' }, @@ -58,7 +58,17 @@ const DEVICE = (device: string) => { }; class Wifi extends Service { - static { Service.register(this); } + static { + Service.register(this, {}, { + 'enabled': ['boolean', 'rw'], + 'internet': ['boolean'], + 'strength': ['int'], + 'access-points': ['jsobject'], + 'ssid': ['string'], + 'state': ['string'], + 'icon-name': ['string'], + }); + } private _client: NM.Client; private _device: NM.DeviceWifi; @@ -95,11 +105,16 @@ class Wifi extends Service { if (!this._ap) return; - this._apBind = this._ap.connect( - 'notify::strength', () => this.emit('changed')); + + // TODO make signals actually signal when they should + this._apBind = this._ap.connect('notify::strength', () => { + this.emit('changed'); + ['enabled', 'internet', 'strength', 'access-points', 'ssid', 'state', 'icon-name'] + .map(prop => this.notify(prop)); + }); } - get accessPoints() { + get access_points() { return this._device.get_access_points().map(ap => ({ bssid: ap.bssid, address: ap.hw_address, @@ -109,7 +124,7 @@ class Wifi extends Service { : 'Unknown', active: ap === this._ap, strength: ap.strength, - iconName: STRENGHT_ICONS.find(({ value }) => value <= ap.strength)?.icon, + iconName: _STRENGTH_ICONS.find(({ value }) => value <= ap.strength)?.icon, })); } @@ -117,7 +132,7 @@ class Wifi extends Service { set enabled(v) { this._client.wireless_enabled = v; } get strength() { return this._ap?.strength || -1; } - get internet() { return INTERNET(this._device); } + get internet() { return _INTERNET(this._device); } get ssid() { if (!this._ap) return ''; @@ -129,8 +144,9 @@ class Wifi extends Service { return NM.utils_ssid_to_utf8(ssid); } - get state() { return DEVICE_STATE(this._device); } - get iconName() { + get state() { return _DEVICE_STATE(this._device); } + + get icon_name() { const iconNames: [number, string][] = [ [80, 'excellent'], [60, 'good'], @@ -157,19 +173,33 @@ class Wifi extends Service { } class Wired extends Service { - static { Service.register(this); } + static { + Service.register(this, {}, { + 'speed': ['int'], + 'internet': ['string'], + 'state': ['string'], + 'icon-name': ['string'], + }); + } private _device: NM.DeviceEthernet; constructor(device: NM.DeviceEthernet) { super(); this._device = device; + + // TODO make signals actually signal when they should + this._device.connect('notify::speed', () => { + this.emit('changed'); + ['speed', 'internet', 'state', 'icon-name'] + .map(prop => this.notify(prop)); + }); } get speed() { return this._device.get_speed(); } - get internet() { return INTERNET(this._device); } - get state() { return DEVICE_STATE(this._device); } - get iconName() { + get internet() { return _INTERNET(this._device); } + get state() { return _DEVICE_STATE(this._device); } + get icon_name() { if (this.internet === 'connecting') return 'network-wired-acquiring-symbolic'; @@ -184,13 +214,21 @@ class Wired extends Service { } class NetworkService extends Service { - static { Service.register(this); } + static { + Service.register(this, {}, { + 'wifi': ['jsobject'], + 'wired': ['jsobject'], + 'primary': ['string'], + 'connectivity': ['string'], + }); + } private _client!: NM.Client; - _wifi!: Wifi; - _wired!: Wired; - _primary?: string; - _connectivity!: string; + + wifi!: Wifi; + wired!: Wired; + primary?: string; + connectivity!: string; constructor() { super(); @@ -216,20 +254,21 @@ class NetworkService extends Service { } private _clientReady() { - bulkConnect((this._client as unknown) as GObject.Object, [ + bulkConnect(this._client as unknown as GObject.Object, [ ['notify::wireless-enabled', this._sync.bind(this)], ['notify::connectivity', this._sync.bind(this)], ['notify::primary-connection', this._sync.bind(this)], ['notify::activating-connection', this._sync.bind(this)], ]); - this._wifi = new Wifi(this._client, + this.wifi = new Wifi(this._client, this._getDevice(NM.DeviceType.WIFI) as NM.DeviceWifi); - this._wifi.connect('changed', this._sync.bind(this)); - this._wired = new Wired( + this.wired = new Wired( this._getDevice(NM.DeviceType.ETHERNET) as NM.DeviceEthernet); - this._wired.connect('changed', this._sync.bind(this)); + + this.wifi.connect('changed', this._sync.bind(this)); + this.wired.connect('changed', this._sync.bind(this)); this._sync(); } @@ -239,8 +278,11 @@ class NetworkService extends Service { this._client.get_primary_connection() || this._client.get_activating_connection(); - this._primary = DEVICE(mainConnection?.type || ''); - this._connectivity = CONNECTIVITY_STATE(this._client); + this.primary = DEVICE(mainConnection?.type || ''); + this.connectivity = _CONNECTIVITY_STATE(this._client); + + this.notify('primary'); + this.notify('connectivity'); this.emit('changed'); } } @@ -254,8 +296,8 @@ export default class Network { } static toggleWifi() { Network.instance.toggleWifi(); } - static get connectivity() { return Network.instance._connectivity; } - static get primary() { return Network.instance._primary; } - static get wifi() { return Network.instance._wifi; } - static get wired() { return Network.instance._wired; } + static get connectivity() { return Network.instance.connectivity; } + static get primary() { return Network.instance.primary; } + static get wifi() { return Network.instance.wifi; } + static get wired() { return Network.instance.wired; } } diff --git a/src/service/notifications.ts b/src/service/notifications.ts index 50a6fdd..d48ef4d 100644 --- a/src/service/notifications.ts +++ b/src/service/notifications.ts @@ -3,7 +3,6 @@ import GdkPixbuf from 'gi://GdkPixbuf'; import GLib from 'gi://GLib'; import Service from './service.js'; import App from '../app.js'; - import { CACHE_DIR, ensureDirectory, loadInterfaceXML, readFileAsync, @@ -14,7 +13,7 @@ const NOTIFICATIONS_CACHE_PATH = `${CACHE_DIR}/notifications`; const CACHE_FILE = NOTIFICATIONS_CACHE_PATH + '/notifications.json'; const NotificationIFace = loadInterfaceXML('org.freedesktop.Notifications'); -interface action { +interface Action { id: string label: string } @@ -25,20 +24,162 @@ interface Hints { 'urgency'?: GLib.Variant<'y'> } -const _URGENCY = ['low', 'normal', 'critical']; - -interface Notification { +interface NotifcationJson { id: number appName: string - appEntry?: string + appEntry: string | null appIcon: string summary: string body: string - actions: action[] + actions: Action[] urgency: string time: number image: string | null - popup: boolean +} + +const _URGENCY = ['low', 'normal', 'critical']; + +class Notification extends Service { + static { + Service.register(this, { + 'dismissed': [], + 'closed': [], + 'invoked': ['string'], + }, { + 'id': ['int'], + 'app-name': ['string'], + 'app-entry': ['string'], + 'app-icon': ['string'], + 'summary': ['string'], + 'body': ['string'], + 'actions': ['jsobject'], + 'urgency': ['string'], + 'time': ['int'], + 'image': ['string'], + 'popup': ['boolean'], + }); + } + + _id: number; + _appName: string; + _appEntry: string | null; + _appIcon: string; + _summary: string; + _body: string; + _actions: Action[] = []; + _urgency: string; + _time: number; + _image: string | null; + _popup: boolean; + + get id() { return this._id; } + get app_name() { return this._appName; } + get app_entry() { return this._appEntry; } + get app_icon() { return this._appIcon; } + get summary() { return this._summary; } + get body() { return this._body; } + get actions() { return this._actions; } + get urgency() { return this._urgency; } + get time() { return this._time; } + get image() { return this._image; } + get popup() { return this._popup; } + + constructor( + appName: string, + id: number, + appIcon: string, + summary: string, + body: string, + acts: string[], + hints: Hints, + popup: boolean, + ) { + super(); + + for (let i = 0; i < acts.length; i += 2) { + acts[i + 1] !== '' && this._actions.push({ + label: acts[i + 1], + id: acts[i], + }); + } + + this._urgency = _URGENCY[hints['urgency']?.unpack() || 1]; + this._id = id; + this._appName = appName; + this._appEntry = hints['desktop-entry']?.unpack() || null; + this._appIcon = appIcon; + this._summary = summary; + this._body = body; + this._time = GLib.DateTime.new_now_local().to_unix(); + this._image = this._parseImageData(hints['image-data']) || this._appIconIsFile(); + this._popup = popup; + } + + dismiss() { + this._popup = false; + this.changed('popup'); + this.emit('dismissed'); + } + + close() { + this.emit('closed'); + } + + invoke(id: string) { + this.emit('invoked', id); + this.close(); + } + + toJson(cacheActions = App.config.cacheNotificationActions) { + return { + id: this._id, + appName: this._appName, + appEntry: this._appEntry, + appIcon: this._appIcon, + summary: this._summary, + body: this._body, + actions: cacheActions ? this._actions : [], + urgency: this._urgency, + time: this._time, + image: this._image, + }; + } + + static fromJson(json: NotifcationJson) { + const { id, appName, appEntry, appIcon, summary, + body, actions, urgency, time, image } = json; + + const n = new Notification(appName, id, appIcon, summary, body, [], {}, false); + n._actions = actions; + n._appEntry = appEntry; + n._urgency = urgency; + n._time = time; + n._image = image; + return n; + } + + private _appIconIsFile() { + return GLib.file_test(this._appIcon, GLib.FileTest.EXISTS) ? this._appIcon : null; + } + + private _parseImageData(imageData?: GLib.Variant<'(iiibiiay)'>) { + if (!imageData) + return null; + + ensureDirectory(NOTIFICATIONS_CACHE_PATH); + const fileName = NOTIFICATIONS_CACHE_PATH + `/${this._id}`; + const [w, h, rs, alpha, bps, _, data] = imageData.recursiveUnpack(); + const pixbuf = GdkPixbuf.Pixbuf.new_from_bytes( + data, GdkPixbuf.Colorspace.RGB, alpha, bps, w, h, rs); + + const outputStream = Gio.File.new_for_path(fileName) + .replace(null, false, Gio.FileCreateFlags.NONE, null); + + pixbuf.save_to_streamv(outputStream, 'png', null, null, null); + outputStream.close(null); + + return fileName; + } } class NotificationsService extends Service { @@ -47,6 +188,10 @@ class NotificationsService extends Service { 'dismissed': ['int'], 'notified': ['int'], 'closed': ['int'], + }, { + 'notifications': ['jsobject'], + 'popups': ['jsobject'], + 'dnd': ['boolean'], }); } @@ -63,26 +208,32 @@ class NotificationsService extends Service { this._register(); } - get dnd() { return this._dnd; } set dnd(value: boolean) { this._dnd = value; - this.emit('changed'); + this.changed('dnd'); + } + + get notifications() { + return Array.from(this._notifications.values()); } - get notifications() { return this._notifications; } get popups() { - const map: Map = new Map(); - for (const [id, notification] of this._notifications) { + const list = []; + for (const [, notification] of this._notifications) { if (notification.popup) - map.set(id, notification); + list.push(notification); } - return map; + return list; } - Clear() { - for (const [id] of this._notifications) - this.CloseNotification(id); + getPopup(id: number) { + const n = this._notifications.get(id); + return n?.popup ? n : null; + } + + getNotification(id: number) { + return this._notifications.get(id); } Notify( @@ -94,74 +245,30 @@ class NotificationsService extends Service { acts: string[], hints: Hints, ) { - const actions: action[] = []; - for (let i = 0; i < acts.length; i += 2) { - if (acts[i + 1] !== '') { - actions.push({ - label: acts[i + 1], - id: acts[i], - }); - } - } - const id = replacesId || this._idCount++; - const urgency = _URGENCY[hints['urgency']?.unpack() || 1]; - this._notifications.set(id, { - id, - appName, - appEntry: hints['desktop-entry']?.unpack(), - appIcon, - summary, - body, - actions, - urgency, - time: GLib.DateTime.new_now_local().to_unix(), - popup: !this._dnd, - image: this._parseImage( - id, hints['image-data']) || - this._isFile(appIcon), - }); - + const n = new Notification(appName, id, appIcon, summary, body, acts, hints, !this.dnd); timeout(App.config.notificationPopupTimeout, () => this.DismissNotification(id)); - - this._cache(); + this._addNotification(n); + !this._dnd && this.notify('popups'); + this.notify('notifications'); this.emit('notified', id); this.emit('changed'); + this._cache(); return id; } - DismissNotification(id: number) { - const n = this._notifications.get(id); - if (!n) - return; + Clear() { this.clear(); } - n.popup = false; - this.emit('dismissed', id); - this.emit('changed'); + DismissNotification(id: number) { + this._notifications.get(id)?.dismiss(); } CloseNotification(id: number) { - if (!this._notifications.has(id)) - return; - - this._dbus.emit_signal('NotificationClosed', - GLib.Variant.new('(uu)', [id, 3])); - - this._notifications.delete(id); - this.emit('closed', id); - this.emit('changed'); - this._cache(); + this._notifications.get(id)?.close(); } InvokeAction(id: number, actionId: string) { - if (!this._notifications.has(id)) - return; - - this._dbus.emit_signal('ActionInvoked', - GLib.Variant.new('(us)', [id, actionId])); - - this.CloseNotification(id); - this._cache(); + this._notifications.get(id)?.invoke(actionId); } GetCapabilities() { @@ -169,12 +276,41 @@ class NotificationsService extends Service { } GetServerInformation() { - return new GLib.Variant('(ssss)', [ - pkg.name, - 'Aylur', - pkg.version, - '1.2', - ]); + return new GLib.Variant('(ssss)', [pkg.name, 'Aylur', pkg.version, '1.2']); + } + + clear() { + for (const [id] of this._notifications) + this.CloseNotification(id); + } + + private _addNotification(n: Notification) { + n.connect('dismissed', this._onDismissed.bind(this)); + n.connect('closed', this._onClosed.bind(this)); + n.connect('invoked', this._onInvoked.bind(this)); + this._notifications.set(n.id, n); + } + + private _onDismissed(n: Notification) { + this.emit('dismissed', n.id); + this.changed('popups'); + } + + private _onClosed(n: Notification) { + this._dbus.emit_signal('NotificationClosed', + GLib.Variant.new('(uu)', [n.id, 3])); + + this._notifications.delete(n.id); + this.notify('notifications'); + this.notify('popups'); + this.emit('closed', n.id); + this.emit('changed'); + this._cache(); + } + + private _onInvoked(n: Notification, id: string) { + this._dbus.emit_signal('ActionInvoked', + GLib.Variant.new('(us)', [n.id, id])); } private _register() { @@ -200,62 +336,33 @@ class NotificationsService extends Service { private async _readFromFile() { try { const file = await readFileAsync(CACHE_FILE); - const notifications = JSON.parse(file as string) as Notification[]; + const notifications = JSON.parse(file) + .map((n: NotifcationJson) => Notification.fromJson(n)); + for (const n of notifications) { + this._addNotification(n); if (n.id > this._idCount) this._idCount = n.id + 1; - - this._notifications.set(n.id, n); } - this.emit('changed'); + this.changed('notifications'); } catch (_) { // most likely there is no cache yet } } - private _isFile(path: string) { - return GLib.file_test(path, GLib.FileTest.EXISTS) ? path : null; - } - - private _parseImage(id: number, image_data?: GLib.Variant<'(iiibiiay)'>) { - if (!image_data) - return null; - - ensureDirectory(NOTIFICATIONS_CACHE_PATH); - const fileName = NOTIFICATIONS_CACHE_PATH + `/${id}`; - const image = image_data.recursiveUnpack(); - const pixbuf = GdkPixbuf.Pixbuf.new_from_bytes( - image[6], - GdkPixbuf.Colorspace.RGB, - image[3], - image[4], - image[0], - image[1], - image[2], - ); - - const output_stream = - Gio.File.new_for_path(fileName) - .replace(null, false, Gio.FileCreateFlags.NONE, null); - - pixbuf.save_to_streamv(output_stream, 'png', null, null, null); - output_stream.close(null); - - return fileName; - } - private _cache() { - const arr = Array.from(this._notifications.values()); - const notifications = App.config.cacheNotificationActions - ? arr : arr.map(n => ({ ...n, actions: [], popup: false })); - ensureDirectory(NOTIFICATIONS_CACHE_PATH); - const json = JSON.stringify(notifications, null, 2); - writeFile(json, CACHE_FILE).catch(logError); + const arr = Array.from(this._notifications.values()).map(n => n.toJson()); + writeFile(JSON.stringify(arr, null, 2), CACHE_FILE).catch(logError); } } +const depracate = (method: string) => console.error( + `Notifications.${method} is DEPRECATED` + + `use the ${method} method on the notification object instead`, +); + export default class Notifications { static _instance: NotificationsService; @@ -264,17 +371,28 @@ export default class Notifications { return Notifications._instance; } - // eslint-disable-next-line max-len - static invoke(id: number, actionId: string) { Notifications.instance.InvokeAction(id, actionId); } - static dismiss(id: number) { Notifications.instance.DismissNotification(id); } - static clear() { Notifications.instance.Clear(); } - static close(id: number) { Notifications.instance.CloseNotification(id); } + static invoke(id: number, actionId: string) { + depracate('invoke'); + Notifications.instance.InvokeAction(id, actionId); + } - static getPopup(id: number) { return Notifications.instance.popups.get(id); } - static getNotification(id: number) { return Notifications.instance.notifications.get(id); } + static dismiss(id: number) { + depracate('dismiss'); + Notifications.instance.DismissNotification(id); + } - static get popups() { return Array.from(Notifications.instance.popups.values()); } - static get notifications() { return Array.from(Notifications.instance.notifications.values()); } + static close(id: number) { + depracate('close'); + Notifications.instance.CloseNotification(id); + } + + static clear() { Notifications.instance.clear(); } + + static getPopup(id: number) { return Notifications.instance.getPopup(id); } + static getNotification(id: number) { return Notifications.instance.getNotification(id); } + + static get popups() { return Notifications.instance.popups; } + static get notifications() { return Notifications.instance.notifications; } static get dnd() { return Notifications.instance.dnd; } static set dnd(value: boolean) { Notifications.instance.dnd = value; } diff --git a/src/service/service.ts b/src/service/service.ts index 61cd07e..1494f7d 100644 --- a/src/service/service.ts +++ b/src/service/service.ts @@ -3,6 +3,9 @@ import Gtk from 'gi://Gtk?version=3.0'; import { connect } from '../utils.js'; import { type Ctor } from 'gi-types/gobject2.js'; +type PspecType = 'jsobject' | 'string' | 'int' | 'float' | 'boolean'; +type PspecFlag = 'rw' | 'r' | 'w'; + export default class Service extends GObject.Object { static { GObject.registerClass({ @@ -16,30 +19,65 @@ export default class Service extends GObject.Object { api._instance = new service(); } - static register(service: Ctor, signals?: { [signal: string]: string[] }) { + static pspec(name: string, type: PspecType = 'jsobject', handle: PspecFlag = 'r') { + const flags = (() => { + switch (handle) { + case 'w': return GObject.ParamFlags.WRITABLE; + case 'r': return GObject.ParamFlags.READABLE; + case 'rw': + default: return GObject.ParamFlags.READWRITE; + } + })(); + + switch (type) { + case 'string': return GObject.ParamSpec.string( + name, name, name, flags, ''); + + case 'int': return GObject.ParamSpec.int64( + name, name, name, flags, + Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, 0); + + case 'float': return GObject.ParamSpec.float( + name, name, name, flags, + -1, 1, 0); + + case 'boolean': return GObject.ParamSpec.boolean( + name, name, name, flags, false); + + // @ts-expect-error + default: return GObject.ParamSpec.jsobject( + name, name, name, flags, null); + } + } + + static register( + service: Ctor, + signals?: { [signal: string]: string[] }, + properties?: { [prop: string]: [type?: PspecType, handle?: PspecFlag] }, + ) { const Signals: { [signal: string]: { param_types: GObject.GType[] } } = {}; + const Properties: { + [prop: string]: GObject.ParamSpec, + } = {}; + if (signals) { - Object.keys(signals).forEach(signal => - Signals[signal] = { - param_types: signals[signal].map(t => - // @ts-expect-error - GObject[`TYPE_${t.toUpperCase()}`]), - }, + Object.keys(signals).forEach(signal => Signals[signal] = { + param_types: signals[signal].map(t => + // @ts-expect-error + GObject[`TYPE_${t.toUpperCase()}`]), + }); + } + + if (properties) { + Object.keys(properties).forEach(prop => + Properties[prop] = Service.pspec(prop, ...properties[prop]), ); } - GObject.registerClass({ Signals }, service); - } - - static export(api: { instance: object }, name: string) { - // @ts-expect-error - Service[name] = api; - console.error('Service.register is DEPRECATED.\n' + - "Simply do Service['YourService'] = YourService\n" + - 'or just export and import your YourService'); + GObject.registerClass({ Signals, Properties }, service); } connectWidget( @@ -49,5 +87,25 @@ export default class Service extends GObject.Object { ) { connect(this, widget, callback, event); } + + updateProperty(prop: string, value: unknown) { + // @ts-expect-error + if (this[`_${prop}`] === value) + return; + + const privateProp = prop + .split('-') + .map((w, i) => i > 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w) + .join(''); + + // @ts-expect-error + this[`_${privateProp}`] = value; + this.notify(prop); + } + + changed(property: string) { + this.notify(property); + this.emit('changed'); + } } diff --git a/src/service/systemtray.ts b/src/service/systemtray.ts index f452976..8c4ed4e 100644 --- a/src/service/systemtray.ts +++ b/src/service/systemtray.ts @@ -19,6 +19,16 @@ export class TrayItem extends Service { Service.register(this, { 'removed': ['string'], 'ready': [], + }, { + 'menu': ['jsobject'], + 'category': ['string'], + 'id': ['string'], + 'title': ['string'], + 'status': ['string'], + 'window-id': ['int'], + 'is-menu': ['boolean'], + 'tooltip-markup': ['string'], + 'icon': ['jsobject'], }); } @@ -70,10 +80,10 @@ export class TrayItem extends Service { get id() { return this._proxy.Id; } get title() { return this._proxy.Title; } get status() { return this._proxy.Status; } - get windowId() { return this._proxy.WindowId; } - get isMenu() { return this._proxy.ItemIsMenu; } + get window_id() { return this._proxy.WindowId; } + get is_menu() { return this._proxy.ItemIsMenu; } - get tooltipMarkup() { + get tooltip_markup() { if (!this._proxy.ToolTip) return ''; @@ -125,20 +135,26 @@ export class TrayItem extends Service { if (!proxy.g_name_owner) this.emit('removed', this._busName); }], - ['g-signal', () => { - this._refreshAllProperties(); - }], + ['g-signal', this._refreshAllProperties.bind(this)], ['g-properties-changed', () => this.emit('changed')], ]); ['Title', 'Icon', 'AttentionIcon', 'OverlayIcon', 'ToolTip', 'Status'] .forEach(prop => proxy.connectSignal(`New${prop}`, () => { - this.emit('changed'); + this._notify(); })); this.emit('ready'); } + _notify() { + [ + 'menu', 'category', 'id', 'title', 'status', + 'window-id', 'is-menu', 'tooltip-markup', 'icon', + ].forEach(prop => this.notify(prop)); + this.emit('changed'); + } + private _refreshAllProperties() { this._proxy.g_connection.call( this._proxy.g_name, @@ -157,8 +173,9 @@ export class TrayItem extends Service { Object.entries(properties).map(([propertyName, value]) => { this._proxy.set_cached_property(propertyName, value); }); - this.emit('changed'); - }); + this._notify(); + }, + ); } private _getPixbuf(pixMapArray: [number, number, Uint8Array][]) { @@ -194,6 +211,8 @@ class SystemTrayService extends Service { Service.register(this, { 'added': ['string'], 'removed': ['string'], + }, { + 'items': ['jsobject'], }); } @@ -203,7 +222,9 @@ class SystemTrayService extends Service { get IsStatusNotifierHostRegistered() { return true; } get ProtocolVersion() { return 0; } get RegisteredStatusNotifierItems() { return Array.from(this._items.keys()); } - get items() { return this._items; } + + get items() { return Array.from(this._items.values()); } + getItem(name: string) { return this._items.get(name); } constructor() { super(); @@ -246,6 +267,7 @@ class SystemTrayService extends Service { item.connect('ready', () => { this._items.set(busName, item); this.emit('added', busName); + this.notify('items'); this.emit('changed'); this._dbus.emit_signal( 'StatusNotifierItemRegistered', @@ -255,6 +277,7 @@ class SystemTrayService extends Service { item.connect('removed', () => { this._items.delete(busName); this.emit('removed', busName); + this.notify('items'); this.emit('changed'); this._dbus.emit_signal( 'StatusNotifierItemUnregistered', @@ -262,10 +285,6 @@ class SystemTrayService extends Service { ); }); } - - getItem(name: string) { - return this._items.get(name); - } } @@ -277,7 +296,7 @@ export default class SystemTray { return SystemTray._instance; } - static get items() { return Array.from(SystemTray.instance.items.values()); } - static getItem(name: string) { return SystemTray._instance.getItem(name); } + static get items() { return SystemTray.instance.items; } + static getItem(name: string) { return SystemTray.instance.getItem(name); } } diff --git a/src/variable.ts b/src/variable.ts index a6e99ec..a468ff0 100644 --- a/src/variable.ts +++ b/src/variable.ts @@ -4,12 +4,12 @@ import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import { execAsync, interval, subprocess } from './utils.js'; -type poll = [number, string[] | string | (() => unknown), (out: string) => string]; -type listen = [string[] | string, (out: string) => string] | string[] | string; +type Poll = [number, string[] | string | (() => unknown), (out: string) => string]; +type Listen = [string[] | string, (out: string) => string] | string[] | string; interface Options { - poll?: poll - listen?: listen + poll?: Poll + listen?: Listen } class AgsVariable extends GObject.Object { @@ -17,12 +17,19 @@ class AgsVariable extends GObject.Object { GObject.registerClass({ GTypeName: 'AgsVariable', Signals: { 'changed': {} }, + Properties: { + // @ts-expect-error + 'value': GObject.ParamSpec.jsobject( + 'value', 'value', 'value', + GObject.ParamFlags.READWRITE, null, + ), + }, }, this); } private _value: any; - private _poll?: poll; - private _listen?: listen; + private _poll?: Poll; + private _listen?: Listen; private _interval?: number; private _subprocess?: Gio.Subprocess | null; @@ -122,6 +129,7 @@ class AgsVariable extends GObject.Object { getValue() { return this._value; } setValue(value: any) { this._value = value; + this.notify('value'); this.emit('changed'); } diff --git a/src/widgets/constructor.ts b/src/widgets/constructor.ts index e19872f..3709432 100644 --- a/src/widgets/constructor.ts +++ b/src/widgets/constructor.ts @@ -27,7 +27,11 @@ interface CommonParams { [Connectable, (...args: unknown[]) => unknown, string] )[] properties?: [prop: string, value: unknown][] - binds?: [prop: string, obj: Connectable, objProp?: string, signal?: string][], + binds?: [ + prop: string, + obj: Connectable, + objProp?: string, + transform?: (value: unknown) => unknown][], setup?: (widget: Gtk.Widget) => void } @@ -91,17 +95,15 @@ function parseCommon(widget: Gtk.Widget, { } if (binds) { - binds.forEach(([prop, obj, value = 'value', signal = 'changed']) => { + binds.forEach(([prop, obj, objProp = 'value', transform = out => out]) => { if (!prop || !obj) { - logError(new Error('missing arguments to connections')); + logError(new Error('missing arguments to binds')); return; } - const callback = () => { - // @ts-expect-error - widget[prop] = obj[value]; - }; - connections.push([obj, callback, signal]); + // @ts-expect-error + const callback = () => widget[prop] = transform(obj[objProp]); + connections.push([obj, callback, `notify::${objProp}`]); }); }