// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect // // SPDX-License-Identifier: GPL-2.0-or-later import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import Device from './device.js'; import plugins from './plugins/index.js'; /** * Get the local device type. * * @returns {string} A device type string */ export function _getDeviceType() { try { let type = GLib.file_get_contents('/sys/class/dmi/id/chassis_type')[1]; type = Number(new TextDecoder().decode(type)); if ([30, 32].includes(type)) return 'tablet'; if ([8, 9, 10, 14, 31].includes(type)) return 'laptop'; return 'desktop'; } catch { return 'desktop'; } } /** * The packet class is a simple Object-derived class, offering some conveniences * for working with KDE Connect packets. */ export class Packet { constructor(data = null) { this.id = 0; this.type = undefined; this.body = {}; if (typeof data === 'string') Object.assign(this, JSON.parse(data)); else if (data !== null) Object.assign(this, data); } [Symbol.toPrimitive](hint) { this.id = Date.now(); if (hint === 'string') return `${JSON.stringify(this)}\n`; if (hint === 'number') return `${JSON.stringify(this)}\n`.length; return true; } get [Symbol.toStringTag]() { return `Packet:${this.type}`; } /** * Deserialize and return a new Packet from an Object or string. * * @param {object|string} data - A string or dictionary to deserialize * @returns {Packet} A new packet object */ static deserialize(data) { return new Packet(data); } /** * Serialize the packet as a single line with a terminating new-line (`\n`) * character, ready to be written to a channel. * * @returns {string} A serialized packet */ serialize() { this.id = Date.now(); return `${JSON.stringify(this)}\n`; } /** * Update the packet from a dictionary or string of JSON * * @param {object|string} data - Source data */ update(data) { try { if (typeof data === 'string') Object.assign(this, JSON.parse(data)); else Object.assign(this, data); } catch (e) { throw Error(`Malformed data: ${e.message}`); } } /** * Check if the packet has a payload. * * @returns {boolean} %true if the packet has a payload */ hasPayload() { if (!this.hasOwnProperty('payloadSize')) return false; if (!this.hasOwnProperty('payloadTransferInfo')) return false; return (Object.keys(this.payloadTransferInfo).length > 0); } } /** * Channel objects handle KDE Connect packet exchange and data transfers for * devices. The implementation is responsible for all negotiation of the * underlying protocol. */ export const Channel = GObject.registerClass({ GTypeName: 'GSConnectChannel', Properties: { 'closed': GObject.ParamSpec.boolean( 'closed', 'Closed', 'Whether the channel has been closed', GObject.ParamFlags.READABLE, false ), }, }, class Channel extends GObject.Object { get address() { throw new GObject.NotImplementedError(); } get backend() { if (this._backend === undefined) this._backend = null; return this._backend; } set backend(backend) { this._backend = backend; } get cancellable() { if (this._cancellable === undefined) this._cancellable = new Gio.Cancellable(); return this._cancellable; } get closed() { if (this._closed === undefined) this._closed = false; return this._closed; } get input_stream() { if (this._input_stream === undefined) { if (this._connection instanceof Gio.IOStream) return this._connection.get_input_stream(); return null; } return this._input_stream; } set input_stream(stream) { this._input_stream = stream; } get output_stream() { if (this._output_stream === undefined) { if (this._connection instanceof Gio.IOStream) return this._connection.get_output_stream(); return null; } return this._output_stream; } set output_stream(stream) { this._output_stream = stream; } get uuid() { if (this._uuid === undefined) this._uuid = GLib.uuid_string_random(); return this._uuid; } set uuid(uuid) { this._uuid = uuid; } /** * Close the channel. */ close() { throw new GObject.NotImplementedError(); } /** * Read a packet. * * @param {Gio.Cancellable} [cancellable] - A cancellable * @returns {Promise} The packet */ async readPacket(cancellable = null) { if (cancellable === null) cancellable = this.cancellable; if (!(this.input_stream instanceof Gio.DataInputStream)) { this.input_stream = new Gio.DataInputStream({ base_stream: this.input_stream, }); } const [data] = await this.input_stream.read_line_async( GLib.PRIORITY_DEFAULT, cancellable); if (data === null) { throw new Gio.IOErrorEnum({ message: 'End of stream', code: Gio.IOErrorEnum.CONNECTION_CLOSED, }); } return new Packet(data); } /** * Send a packet. * * @param {Packet} packet - The packet to send * @param {Gio.Cancellable} [cancellable] - A cancellable * @returns {Promise} %true if successful */ sendPacket(packet, cancellable = null) { if (cancellable === null) cancellable = this.cancellable; return this.output_stream.write_all_async(packet.serialize(), GLib.PRIORITY_DEFAULT, cancellable); } /** * Reject a transfer. * * @param {Packet} packet - A packet with payload info */ rejectTransfer(packet) { throw new GObject.NotImplementedError(); } /** * Download a payload from a device. Typically implementations will override * this with an async function. * * @param {Packet} packet - A packet * @param {Gio.OutputStream} target - The target stream * @param {Gio.Cancellable} [cancellable] - A cancellable for the upload */ download(packet, target, cancellable = null) { throw new GObject.NotImplementedError(); } /** * Upload a payload to a device. Typically implementations will override * this with an async function. * * @param {Packet} packet - The packet describing the transfer * @param {Gio.InputStream} source - The source stream * @param {number} size - The payload size * @param {Gio.Cancellable} [cancellable] - A cancellable for the upload */ upload(packet, source, size, cancellable = null) { throw new GObject.NotImplementedError(); } }); /** * ChannelService implementations provide Channel objects, emitting the * ChannelService::channel signal when a new connection has been accepted. */ export const ChannelService = GObject.registerClass({ GTypeName: 'GSConnectChannelService', Properties: { 'active': GObject.ParamSpec.boolean( 'active', 'Active', 'Whether the service is active', GObject.ParamFlags.READABLE, false ), 'id': GObject.ParamSpec.string( 'id', 'ID', 'The hostname or other network unique id', GObject.ParamFlags.READWRITE, null ), 'name': GObject.ParamSpec.string( 'name', 'Name', 'The name of the backend', GObject.ParamFlags.READWRITE, null ), }, Signals: { 'channel': { flags: GObject.SignalFlags.RUN_LAST, param_types: [Channel.$gtype], return_type: GObject.TYPE_BOOLEAN, }, }, }, class ChannelService extends GObject.Object { get active() { if (this._active === undefined) this._active = false; return this._active; } get cancellable() { if (this._cancellable === undefined) this._cancellable = new Gio.Cancellable(); return this._cancellable; } get name() { if (this._name === undefined) this._name = GLib.get_host_name().slice(0, 32); return this._name; } set name(name) { if (this.name === name) return; this._name = name; this.notify('name'); } get id() { if (this._id === undefined) this._id = Device.generateId(); return this._id; } set id(id) { if (this.id === id) return; this._id = id; } get identity() { if (this._identity === undefined) this.buildIdentity(); return this._identity; } /** * Broadcast directly to {@link address} or the whole network if %null * * @param {string} [address] - A string address */ broadcast(address = null) { throw new GObject.NotImplementedError(); } /** * Rebuild the identity packet used to identify the local device. An * implementation may override this to make modifications to the default * capabilities if necessary (eg. bluez without SFTP support). */ buildIdentity() { this._identity = new Packet({ id: 0, type: 'kdeconnect.identity', body: { deviceId: this.id, deviceName: this.name, deviceType: _getDeviceType(), protocolVersion: 8, incomingCapabilities: [], outgoingCapabilities: [], }, }); for (const name in plugins) { const meta = plugins[name].Metadata; if (meta === undefined) continue; for (const type of meta.incomingCapabilities) this._identity.body.incomingCapabilities.push(type); for (const type of meta.outgoingCapabilities) this._identity.body.outgoingCapabilities.push(type); } } /** * Emit Core.ChannelService::channel * * @param {Channel} channel - The new channel */ channel(channel) { if (!this.emit('channel', channel)) channel.close(); } /** * Start the channel service. Implementations should throw an error if the * service fails to meet any of its requirements for opening or accepting * connections. */ start() { throw new GObject.NotImplementedError(); } /** * Stop the channel service. */ stop() { throw new GObject.NotImplementedError(); } /** * Destroy the channel service. */ destroy() { } }); /** * A class representing a file transfer. */ export const Transfer = GObject.registerClass({ GTypeName: 'GSConnectTransfer', Properties: { 'channel': GObject.ParamSpec.object( 'channel', 'Channel', 'The channel that owns this transfer', GObject.ParamFlags.READWRITE, Channel.$gtype ), 'completed': GObject.ParamSpec.boolean( 'completed', 'Completed', 'Whether the transfer has completed', GObject.ParamFlags.READABLE, false ), 'device': GObject.ParamSpec.object( 'device', 'Device', 'The device that created this transfer', GObject.ParamFlags.READWRITE, GObject.Object.$gtype ), }, }, class Transfer extends GObject.Object { _init(params = {}) { super._init(params); this._cancellable = new Gio.Cancellable(); this._items = []; } get channel() { if (this._channel === undefined) this._channel = null; return this._channel; } set channel(channel) { if (this.channel === channel) return; this._channel = channel; } get completed() { if (this._completed === undefined) this._completed = false; return this._completed; } get device() { if (this._device === undefined) this._device = null; return this._device; } set device(device) { if (this.device === device) return; this._device = device; } get uuid() { if (this._uuid === undefined) this._uuid = GLib.uuid_string_random(); return this._uuid; } /** * Ensure there is a stream for the transfer item. * * @param {object} item - A transfer item * @param {Gio.Cancellable} [cancellable] - A cancellable */ async _ensureStream(item, cancellable = null) { // This is an upload from a remote device if (item.packet.hasPayload()) { if (item.target instanceof Gio.OutputStream) return; if (item.file instanceof Gio.File) { item.target = await item.file.replace_async( null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, GLib.PRIORITY_DEFAULT, this._cancellable); } } else { if (item.source instanceof Gio.InputStream) return; if (item.file instanceof Gio.File) { const read = item.file.read_async(GLib.PRIORITY_DEFAULT, cancellable); const query = item.file.query_info_async( Gio.FILE_ATTRIBUTE_STANDARD_SIZE, Gio.FileQueryInfoFlags.NONE, GLib.PRIORITY_DEFAULT, cancellable); const [stream, info] = await Promise.all([read, query]); item.source = stream; item.size = info.get_size(); } } } /** * Add a file to the transfer. * * @param {Packet} packet - A packet * @param {Gio.File} file - A file to transfer */ addFile(packet, file) { const item = { packet: new Packet(packet), file: file, source: null, target: null, }; this._items.push(item); } /** * Add a filepath to the transfer. * * @param {Packet} packet - A packet * @param {string} path - A filepath to transfer */ addPath(packet, path) { const item = { packet: new Packet(packet), file: Gio.File.new_for_path(path), source: null, target: null, }; this._items.push(item); } /** * Add a stream to the transfer. * * @param {Packet} packet - A packet * @param {Gio.InputStream|Gio.OutputStream} stream - A stream to transfer * @param {number} [size] - Payload size */ addStream(packet, stream, size = 0) { const item = { packet: new Packet(packet), file: null, source: null, target: null, size: size, }; if (stream instanceof Gio.InputStream) item.source = stream; else if (stream instanceof Gio.OutputStream) item.target = stream; this._items.push(item); } /** * Execute a transfer operation. Implementations may override this, while * the default uses g_output_stream_splice(). * * @param {Gio.Cancellable} [cancellable] - A cancellable */ async start(cancellable = null) { let error = null; try { let item; // If a cancellable is passed in, chain to its signal if (cancellable instanceof Gio.Cancellable) cancellable.connect(() => this._cancellable.cancel()); while ((item = this._items.shift())) { // If created for a device, ignore connection changes by // ensuring we have the most recent channel if (this.device !== null) this._channel = this.device.channel; // TODO: transfer queueing? if (this.channel === null || this.channel.closed) { throw new Gio.IOErrorEnum({ code: Gio.IOErrorEnum.CONNECTION_CLOSED, message: 'Channel is closed', }); } await this._ensureStream(item, this._cancellable); if (item.packet.hasPayload()) { await this.channel.download(item.packet, item.target, this._cancellable); } else { await this.channel.upload(item.packet, item.source, item.size, this._cancellable); } } } catch (e) { error = e; } finally { this._completed = true; this.notify('completed'); } if (error !== null) throw error; } cancel() { if (this._cancellable.is_cancelled() === false) this._cancellable.cancel(); } });