gnim
Version:
Library which brings JSX and reactivity to GNOME JavaScript.
869 lines (762 loc) • 27.8 kB
text/typescript
/**
* A {@link Service} currently only allows interfacing with a single interface of a remote object.
* In the future I want to come up with an API to be able to create Service objects for multiple
* interfaces of an object at the same time. Example usage would be for example combining
* "org.mpris.MediaPlayer2" and "org.mpris.MediaPlayer2.Player" into a single object.
*/
import Gio from "gi://Gio"
import GLib from "gi://GLib"
import GObject from "gi://GObject"
import { definePropertyGetter, kebabify, xml } from "./util.js"
import {
register,
property as gproperty,
signal as gsignal,
getter as ggetter,
setter as gsetter,
} from "./gobject.js"
const DEFAULT_TIMEOUT = 10_000
export const Variant = GLib.Variant
export type Variant<T extends string> = GLib.Variant<T>
const info = Symbol("dbus interface info")
const internals = Symbol("dbus interface internals")
const remoteMethod = Symbol("proxy remoteMethod")
const remoteMethodAsync = Symbol("proxy remoteMethodAsync")
const remotePropertySet = Symbol("proxy remotePropertySet")
type DeepInfer<S extends string> = ReturnType<GLib.Variant<S>["deepUnpack"]>
type Ctx = { private: false; static: false; name: string }
/**
* Base type for DBus services and proxies. Interface name is set with
* the {@link iface} decorator which also register it as a GObject type.
*/
export class Service extends GObject.Object {
static [info]?: Gio.DBusInterfaceInfo
static {
GObject.registerClass(this)
}
[internals]: {
dbusObject?: Gio.DBusExportedObject
proxy?: Gio.DBusProxy
priv: Record<string | symbol, unknown>
onStop: Set<() => void>
} = {
priv: {},
onStop: new Set<() => void>(),
}
#info: Gio.DBusInterfaceInfo
constructor() {
super()
const service = this.constructor as unknown as typeof Service
if (!service[info]) throw Error("missing interface info")
this.#info = service[info]
}
notify(propertyName: Extract<keyof this, string> | (string & {})): void {
const prop = this.#info.lookup_property(propertyName)
if (prop && this[internals].dbusObject) {
this[internals].dbusObject.emit_property_changed(
propertyName,
new GLib.Variant(prop.signature, this[propertyName as keyof this]),
)
}
super.notify(prop ? kebabify(propertyName) : propertyName)
}
emit(name: string, ...params: unknown[]): void {
const signal = this.#info.lookup_signal(name)
if (signal && this[internals].dbusObject) {
const signature = `(${signal.args.map((a) => a.signature).join("")})`
this[internals].dbusObject.emit_signal(name, new GLib.Variant(signature, params))
}
return super.emit(signal ? kebabify(name) : name, ...params)
}
// server
#handlePropertyGet(_: Gio.DBusExportedObject, propertyName: Extract<keyof this, string>) {
const prop = this.#info.lookup_property(propertyName)
if (!prop) {
throw Error(`${this.constructor.name} has no exported property: "${propertyName}"`)
}
const value = this[propertyName]
if (typeof value !== "undefined") {
return new GLib.Variant(prop.signature, value)
} else {
return null
}
}
// server
#handlePropertySet(
_: Gio.DBusExportedObject,
propertyName: Extract<keyof this, string>,
value: GLib.Variant,
) {
const newValue = value.deepUnpack()
const prop = this.#info.lookup_property(propertyName)
if (!prop) {
throw Error(`${this.constructor.name} has no property: "${propertyName}"`)
}
if (this[propertyName] !== newValue) {
this[propertyName] = value.deepUnpack<any>()
}
}
// server
#returnError(error: unknown, invocation: Gio.DBusMethodInvocation) {
console.error(error)
if (error instanceof GLib.Error) {
return invocation.return_gerror(error)
}
if (error instanceof Error) {
return invocation.return_dbus_error(
error.name.includes(".") ? error.name : `gjs.JSError.${error.name}`,
error.message,
)
}
invocation.return_dbus_error("gjs.DBusService.UnknownError", `${error}`)
}
// server
#returnValue(value: unknown, methodName: string, invocation: Gio.DBusMethodInvocation) {
if (value === null || value === undefined) {
return invocation.return_value(new GLib.Variant("()", []))
}
const args = this.#info.lookup_method(methodName)?.out_args ?? []
const signature = `(${args.map((arg) => arg.signature).join("")})`
if (!Array.isArray(value)) throw Error("value has to be a tuple")
invocation.return_value(new GLib.Variant(signature, value))
}
// server
#handleMethodCall(
_: Gio.DBusExportedObject,
methodName: Extract<keyof this, string>,
parameters: GLib.Variant,
invocation: Gio.DBusMethodInvocation,
): void {
try {
const value = (this[methodName] as (...args: unknown[]) => unknown)(
...parameters.deepUnpack<Array<unknown>>(),
)
if (value instanceof GLib.Variant) {
invocation.return_value(value)
} else if (value instanceof Promise) {
value
.then((value) => this.#returnValue(value, methodName, invocation))
.catch((error) => this.#returnError(error, invocation))
} else {
this.#returnValue(value, methodName, invocation)
}
} catch (error) {
this.#returnError(error, invocation)
}
}
// server
async serve({
busType = Gio.BusType.SESSION,
name = this.#info.name,
objectPath = "/" + this.#info.name.split(".").join("/"),
flags = Gio.BusNameOwnerFlags.NONE,
timeout = DEFAULT_TIMEOUT,
}: {
busType?: Gio.BusType
name?: string
objectPath?: string
flags?: Gio.BusNameOwnerFlags
timeout?: number
} = {}): Promise<this> {
const impl = new Gio.DBusExportedObject(
// @ts-expect-error missing constructor type
{ g_interface_info: this.#info },
)
impl.connect("handle-method-call", this.#handleMethodCall.bind(this))
impl.connect("handle-property-get", this.#handlePropertyGet.bind(this))
impl.connect("handle-property-set", this.#handlePropertySet.bind(this))
this.#info.cache_build()
return new Promise((resolve, reject) => {
let source =
timeout > 0
? setTimeout(() => {
reject(Error(`serve timed out`))
source = null
}, timeout)
: null
const clear = () => {
if (source) {
clearTimeout(source)
source = null
}
}
const busId = Gio.bus_own_name(
busType,
name,
flags,
(conn: Gio.DBusConnection) => {
try {
impl.export(conn, objectPath)
this[internals].dbusObject = impl
this[internals].onStop.add(() => {
Gio.bus_unown_name(busId)
impl.unexport()
this.#info.cache_release()
delete this[internals].dbusObject
})
resolve(this)
} catch (error) {
reject(error)
}
},
clear,
clear,
)
})
}
// proxy
#handlePropertiesChanged(
_: Gio.DBusProxy,
changed: GLib.Variant<"a{sv}">,
invalidated: string[],
) {
const set = new Set([...Object.keys(changed.deepUnpack()), ...invalidated])
for (const prop of set.values()) {
this.notify(prop as Extract<keyof this, string>)
}
}
// proxy
#handleSignal(
_: Gio.DBusProxy,
_sender: string | null,
signal: string,
parameters: GLib.Variant,
) {
this.emit(kebabify(signal), ...parameters.deepUnpack<Array<unknown>>())
}
// proxy
#remoteMethodParams(
methodName: string,
args: unknown[],
): Parameters<Gio.DBusProxy["call_sync"]> {
const { proxy } = this[internals]
if (!proxy) throw Error("invalid remoteMethod invocation: not a proxy")
const method = this.#info.lookup_method(methodName)
if (!method) throw Error("method not found")
const signature = `(${method.in_args.map((a) => a.signature).join("")})`
return [
methodName,
new GLib.Variant(signature, args),
Gio.DBusCallFlags.NONE,
DEFAULT_TIMEOUT,
null,
]
}
// proxy
[remoteMethod](methodName: string, args: unknown[]): GLib.Variant {
const params = this.#remoteMethodParams(methodName, args)
return this[internals].proxy!.call_sync(...params)
}
// proxy
[remoteMethodAsync](methodName: string, args: unknown[]): Promise<GLib.Variant> {
return new Promise((resolve, reject) => {
try {
const params = this.#remoteMethodParams(methodName, args)
this[internals].proxy!.call(...params, (_, res) => {
try {
resolve(this[internals].proxy!.call_finish(res))
} catch (error) {
reject(error)
}
})
} catch (error) {
reject(error)
}
})
}
// proxy
[remotePropertySet](name: string, value: unknown) {
const proxy = this[internals].proxy!
const prop = this.#info.lookup_property(name)!
const variant = new GLib.Variant(prop.signature, value)
proxy.set_cached_property(name, variant)
proxy.call(
"org.freedesktop.DBus.Properties.Set",
new GLib.Variant("(ssv)", [proxy.gInterfaceName, name, variant]),
Gio.DBusCallFlags.NONE,
-1,
null,
(_, res) => {
try {
proxy.call_finish(res)
} catch (e) {
console.error(e)
}
},
)
}
// proxy
async proxy({
bus = Gio.DBus.session,
name = this.#info.name,
objectPath = "/" + this.#info.name.split(".").join("/"),
flags = Gio.DBusProxyFlags.NONE,
timeout = DEFAULT_TIMEOUT,
}: {
bus?: Gio.DBusConnection
name?: string
objectPath?: string
flags?: Gio.DBusProxyFlags
timeout?: number
} = {}): Promise<this> {
const proxy = new Gio.DBusProxy({
gConnection: bus,
gInterfaceName: this.#info.name,
gInterfaceInfo: this.#info,
gName: name,
gFlags: flags,
gObjectPath: objectPath,
})
return new Promise((resolve, reject) => {
const cancallable = new Gio.Cancellable()
let source =
timeout > 0
? setTimeout(() => {
reject(Error(`proxy timed out`))
source = null
cancallable.cancel()
}, timeout)
: null
proxy.init_async(GLib.PRIORITY_DEFAULT, cancallable, (_, res) => {
try {
if (source) {
clearTimeout(source)
source = null
}
proxy.init_finish(res)
this[internals].proxy = proxy
const ids = [
proxy.connect("g-signal", this.#handleSignal.bind(this)),
proxy.connect(
"g-properties-changed",
this.#handlePropertiesChanged.bind(this),
),
]
this[internals].onStop.add(() => {
ids.forEach((id) => proxy.disconnect(id))
delete this[internals].proxy
})
resolve(this)
} catch (error) {
reject(error)
}
})
})
}
stop() {
const { onStop } = this[internals]
for (const cb of onStop.values()) {
onStop.delete(cb)
cb()
}
}
}
type InterfaceMeta = {
dbusMethods?: Record<
string,
Array<{
name?: string
type: string
direction: "in" | "out"
}>
>
dbusSignals?: Record<
string,
Array<{
name?: string
type: string
}>
>
dbusProperties?: Record<
string,
{
name: string
type: string
read?: true
write?: true
}
>
}
/**
* Registers a {@link Service} as a dbus interface.
*
* @param name Interface name of the object. For example "org.gnome.Shell.SearchProvider2"
* @param options optional properties to pass to {@link register}
*/
export function iface(name: string, options?: Parameters<typeof register>[0]) {
return function (cls: { new (...args: any[]): Service }, ctx: ClassDecoratorContext) {
const meta = ctx.metadata
if (!meta) throw Error(`${cls.name} is not an interface`)
const { dbusMethods = {}, dbusSignals = {}, dbusProperties = {} } = meta as InterfaceMeta
const infoXml = xml({
name: "node",
children: [
{
name: "interface",
attributes: { name },
children: [
...Object.entries(dbusMethods).map(([name, args]) => ({
name: "method",
attributes: { name },
children: args.map((arg) => ({ name: "arg", attributes: arg })),
})),
...Object.entries(dbusSignals).map(([name, args]) => ({
name: "signal",
attributes: { name },
children: args.map((arg) => ({ name: "arg", attributes: arg })),
})),
...Object.values(dbusProperties).map(({ name, type, read, write }) => ({
name: "property",
attributes: {
...(name && { name }),
type,
access: (read ? "read" : "") + (write ? "write" : ""),
},
})),
],
},
],
})
Object.assign(cls, { [info]: Gio.DBusInterfaceInfo.new_for_xml(infoXml) })
register(options)(cls, ctx)
}
}
type DBusType = string | { type: string; name: string }
type InferVariantTypes<T extends Array<DBusType>> = {
[K in keyof T]: T[K] extends string
? DeepInfer<T[K]>
: T[K] extends { type: infer S }
? S extends string
? DeepInfer<S>
: never
: unknown
}
function installMethod<Args extends Array<DBusType>>(
args: Args | [Args, Args?],
method: (...args: any[]) => unknown,
ctx: ClassMethodDecoratorContext<Service, typeof method>,
) {
const name = ctx.name
const meta = ctx.metadata! as InterfaceMeta
const methods = (meta.dbusMethods ??= {})
if (typeof name !== "string") {
throw Error("only string named methods are allowed")
}
const [inArgs, outArgs = []] = (Array.isArray(args[0]) ? args : [args]) as [Args, Args]
methods[name] = [
...inArgs.map((arg) => ({
direction: "in" as const,
...(typeof arg === "string" ? { type: arg } : arg),
})),
...outArgs.map((arg) => ({
direction: "out" as const,
...(typeof arg === "string" ? { type: arg } : arg),
})),
]
return name
}
function installProperty<T extends string>(
type: T,
ctx: ClassFieldDecoratorContext | ClassGetterDecoratorContext | ClassSetterDecoratorContext,
) {
const kind = ctx.kind
const name = ctx.name
const meta = ctx.metadata! as InterfaceMeta
const properties = (meta.dbusProperties ??= {})
if (typeof name !== "string") {
throw Error("only string named properties are allowed")
}
const read = kind === "field" || kind === "getter"
const write = kind === "field" || kind === "setter"
if (name in properties) {
if (write) properties[name].write = true
if (read) properties[name].read = true
} else {
properties[name] = {
name,
type,
...(read && { read }),
...(write && { write }),
}
}
return name
}
function installSignal<Params extends Array<DBusType>>(
params: Params,
ctx: ClassMethodDecoratorContext<Service>,
) {
const name = ctx.name
const meta = ctx.metadata! as InterfaceMeta
const signals = (meta.dbusSignals ??= {})
if (typeof name === "symbol") {
throw Error("symbols are not valid signals")
}
signals[name] = params.map((arg) => (typeof arg === "string" ? { type: arg } : arg))
return name
}
function inferGTypeFromVariant(type: DBusType): GObject.GType<any> {
if (typeof type !== "string") return inferGTypeFromVariant(type.type)
if (type.startsWith("a") || type.startsWith("(")) {
return GObject.TYPE_JSOBJECT
}
switch (type) {
case "v":
return GObject.TYPE_VARIANT
case "b":
return GObject.TYPE_BOOLEAN
case "y":
return GObject.TYPE_UINT
case "n":
return GObject.TYPE_INT
case "q":
return GObject.TYPE_UINT
case "i":
return GObject.TYPE_INT
case "u":
return GObject.TYPE_UINT
case "x":
return GObject.TYPE_INT64
case "t":
return GObject.TYPE_UINT64
case "h":
return GObject.TYPE_INT
case "d":
return GObject.TYPE_DOUBLE
case "s":
case "g":
case "o":
return GObject.TYPE_STRING
default:
break
}
throw Error(`cannot infer GType from variant "${type}"`)
}
/**
* Registers a method.
* You should prefer using {@link methodAsync} when proxying, due to IO blocking.
* Note that this is functionally the same as {@link methodAsync} on exported objects.
* ```
*/
export function method<const InArgs extends Array<DBusType>, const OutArgs extends Array<DBusType>>(
inArgs: InArgs,
outArgs: OutArgs,
): (
method: (this: Service, ...args: InferVariantTypes<InArgs>) => InferVariantTypes<OutArgs>,
ctx: ClassMethodDecoratorContext<Service, typeof method>,
) => void
/**
* Registers a method.
* You should prefer using {@link methodAsync} when proxying, due to IO blocking.
* Note that this is functionally the same as {@link methodAsync} on exported objects.
* ```
*/
export function method<const InArgs extends Array<DBusType>>(
...inArgs: InArgs
): (
method: (this: Service, ...args: InferVariantTypes<InArgs>) => void,
ctx: ClassMethodDecoratorContext<Service, typeof method>,
) => void
export function method<const InArgs extends Array<DBusType>, const OutArgs extends Array<DBusType>>(
...args: InArgs | [inArgs: InArgs, outArgs?: OutArgs]
) {
return function (
method: (
this: Service,
...args: InferVariantTypes<InArgs>
) => InferVariantTypes<OutArgs> | void,
ctx: ClassMethodDecoratorContext<Service, typeof method>,
): typeof method {
const name = installMethod(args, method, ctx)
return function (...args: InferVariantTypes<InArgs>) {
if (this[internals].proxy) {
const value = this[remoteMethod](name, args)
return value.deepUnpack<InferVariantTypes<OutArgs>>()
} else {
return method.apply(this, args)
}
}
}
}
/**
* Registers a method.
* You should prefer using this over {@link method} when proxying, since this does not block IO.
* Note that this is functionally the same as {@link method} on exported objects.
* ```
*/
export function methodAsync<
const InArgs extends Array<DBusType>,
const OutArgs extends Array<DBusType>,
>(
inArgs: InArgs,
outArgs: OutArgs,
): (
method: (
this: Service,
...args: InferVariantTypes<InArgs>
) => Promise<InferVariantTypes<OutArgs>>,
ctx: ClassMethodDecoratorContext<Service, typeof method>,
) => void
/**
* Registers a method.
* You should prefer using this over {@link method} when proxying, since this does not block IO.
* Note that this is functionally the same as {@link method} on exported objects.
* ```
*/
export function methodAsync<const InArgs extends Array<DBusType>>(
...inArgs: InArgs
): (
method: (this: Service, ...args: InferVariantTypes<InArgs>) => Promise<void>,
ctx: ClassMethodDecoratorContext<Service, typeof method>,
) => void
export function methodAsync<
const InArgs extends Array<DBusType>,
const OutArgs extends Array<DBusType>,
>(...args: InArgs | [inArgs: InArgs, outArgs?: OutArgs]) {
return function (
method: (
this: Service,
...args: InferVariantTypes<InArgs>
) => Promise<InferVariantTypes<OutArgs> | void>,
ctx: ClassMethodDecoratorContext<Service, typeof method>,
): typeof method {
const name = installMethod(args, method, ctx)
return async function (...args: InferVariantTypes<InArgs>) {
if (this[internals].proxy) {
const value = await this[remoteMethodAsync](name, args)
return value.deepUnpack<InferVariantTypes<OutArgs>>()
} else {
return method.apply(this, args)
}
}
}
}
/**
* Registers a read-write property. When a new value is assigned the notify signal
* is automatically emitted on the local and exported object.
*
* Note that new values are checked by reference so assigning the same object will
* not emit the notify signal.
* ```
*/
export function property<T extends string>(type: T) {
return function (
_: void,
ctx: ClassFieldDecoratorContext<Service, DeepInfer<T>>,
): (this: Service, init: DeepInfer<T>) => DeepInfer<T> {
const name = installProperty(type, ctx)
void gproperty({ $gtype: inferGTypeFromVariant(type) })(
_,
ctx as ClassFieldDecoratorContext<GObject.Object> & Ctx,
{ metaOnly: true },
)
ctx.addInitializer(function () {
Object.defineProperty(this, name, {
configurable: false,
enumerable: true,
set(value: DeepInfer<T>) {
const { proxy, priv } = this[internals]
if (proxy) {
this[remotePropertySet](name, value)
return
}
if (priv[name] !== value) {
priv[name] = value
this.notify(name as Extract<keyof Service, string>)
}
},
get(): DeepInfer<T> {
const { proxy, priv } = this[internals]
return proxy
? proxy.get_cached_property(name)!.deepUnpack<DeepInfer<T>>()
: (priv[name] as DeepInfer<T>)
},
} satisfies ThisType<Service>)
})
return function (init) {
const priv = this[internals].priv
priv[name] = init
// we don't need to store the value on the object
return void 0 as unknown as DeepInfer<T>
}
}
}
/**
* Registers a read-only property. Can be used in conjuction with {@link setter} to define
* read-write properties as accessors.
*
* Note that you will need to explicitly emit the notify signal.
*/
export function getter<T extends string>(type: T) {
return function (
getter: (this: Service) => DeepInfer<T>,
ctx: ClassGetterDecoratorContext<Service, DeepInfer<T>>,
): (this: Service) => DeepInfer<T> {
const name = installProperty(type, ctx)
ctx.addInitializer(function () {
definePropertyGetter(this, name as Extract<keyof Service, string>)
})
void ggetter({ $gtype: inferGTypeFromVariant(type) })(
() => {},
ctx as ClassGetterDecoratorContext<GObject.Object> & Ctx,
)
return function () {
const { proxy } = this[internals]
return proxy
? proxy.get_cached_property(name)!.deepUnpack<DeepInfer<T>>()
: getter.call(this)
}
}
}
/**
* Registers a write-only property. Can be used in conjuction with {@link getter} to define
* read-write properties as accessors.
*
* Note that you will need to explicitly emit the notify signal.
*/
export function setter<T extends string>(type: T) {
return function (
setter: (this: Service, value: DeepInfer<T>) => void,
ctx: ClassSetterDecoratorContext<Service, DeepInfer<T>>,
): (this: Service, value: DeepInfer<T>) => void {
const name = installProperty(type, ctx)
void gsetter({ $gtype: inferGTypeFromVariant(type) })(
() => {},
ctx as ClassSetterDecoratorContext<GObject.Object> & Ctx,
)
return function (value: DeepInfer<T>) {
const { proxy } = this[internals]
if (proxy) {
this[remotePropertySet](name, value)
} else {
setter.call(this, value)
}
}
}
}
/**
* Registers a signal which when invoked will emit the signal
* on the local object and the exported object.
*
* **Note**: its not possible to emit signals on remote objects through proxies.
*/
export function signal<const Params extends Array<DBusType>>(...params: Params) {
return function (
method: (this: Service, ...params: InferVariantTypes<Params>) => void,
ctx: ClassMethodDecoratorContext<Service, typeof method>,
): typeof method {
const name = installSignal(params, ctx)
void gsignal(...params.map(inferGTypeFromVariant))(
() => {},
ctx as ClassMethodDecoratorContext<GObject.Object> & Ctx,
)
return function (...params: InferVariantTypes<Params>) {
if (this[internals].proxy) {
console.warn(`cannot emit signal "${name}" on remote object`)
}
if (this[internals].dbusObject || !this[internals].proxy) {
method.apply(this, params)
}
return this.emit(name, ...params)
}
}
}