/* DING: Desktop Icons New Generation for GNOME Shell * * Copyright (C) 2022 Sergio Costas (sergio.costas@canonical.com) * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3 of the License. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ 'use strict'; const GLib = imports.gi.GLib; const Gio = imports.gi.Gio; const Gtk = imports.gi.Gtk; var GnomeAutoar = null; try { GnomeAutoar = imports.gi.GnomeAutoar; } catch (e) { } const Enums = imports.enums; const FileUtils = imports.fileUtils; const Prefs = imports.preferences; const Signals = imports.signals; const Gettext = imports.gettext.domain('ding'); const _ = Gettext.gettext; var AutoAr = class { constructor(desktopManager) { this._desktopManager = desktopManager; this._progressWindow = new Gtk.Window({ title: 'Archives Operations', resizable: false, deletable: false, modal: false, default_height: 100, window_position: Gtk.WindowPosition.CENTER_ALWAYS, }); this._progressWindow.connect('delete-event', () => { return true; }); this._progressContainer = new Gtk.Box({ spacing: 12, margin_top: 15, margin_bottom: 15, margin_start: 30, margin_end: 30, halign: Gtk.Align.CENTER, orientation: Gtk.Orientation.VERTICAL, }); this._inhibitCookie = null; this._progressContainer.connect('remove', () => { this._progressElements--; if (this._progressElements == 0) { this._progressWindow.hide(); if (this._inhibitCookie !== null) { this._desktopManager.mainApp.uninhibit(this._inhibitCookie); this._inhibitCookie = null; } } this.emit('progress-elements-changed', this._progressElements); }); this._progressElements = 0; const scroll = new Gtk.ScrolledWindow({ propagate_natural_width: true, min_content_height: 300, }); scroll.hscrollbar_policy = Gtk.PolicyType.NEVER; scroll.vscrollbar_policy = Gtk.PolicyType.AUTOMATIC; this._progressWindow.add(scroll); const viewport = new Gtk.Viewport(); scroll.add(viewport); viewport.add(this._progressContainer); this._refreshExtensions(); } checkAutoAr() { if (GnomeAutoar === null) { this._desktopManager.dbusManager.doNotify(_('AutoAr is not installed'), _('To be able to work with compressed files, install file-roller and/or gir-1.2-gnomeAutoAr')); } return GnomeAutoar !== null; } _refreshExtensions() { this._formats = []; this._filters = []; this._extensions = {}; this._combinedExtensions = {}; if (!GnomeAutoar) { return; } const lastFormat = GnomeAutoar.format_last(); const lastFilter = GnomeAutoar.filter_last(); for (let format = 0; format <= lastFormat; format++) { try { if (!GnomeAutoar.format_is_valid(format)) { continue; } } catch (e) { continue; } this._formats.push(format); const extension = GnomeAutoar.format_get_extension(format); if (!extension) { continue; } this._extensions[extension] = { extension, format, filter: null, }; } for (let filter = 0; filter <= lastFilter; filter++) { try { if (!GnomeAutoar.filter_is_valid(filter)) { continue; } } catch (e) { continue; } this._filters.push(filter); const extension = GnomeAutoar.filter_get_extension(filter); if (!extension) { continue; } this._extensions[extension] = { extension, format: null, filter, }; } for (let format of this._formats) { for (let filter of this._filters) { const extension = GnomeAutoar.format_filter_get_extension(format, filter); if (!extension) { continue; } this._combinedExtensions[extension] = { extension, format, filter, }; } } } extensionIsAvailable(extension) { return (extension in this._extensions) || (extension in this._combinedExtensions); } getFormatAndFilterForExtension(extension) { if (extension in this._extensions) { return this._extensions[extension]; } if (extension in this._combinedExtensions) { return this._combinedExtensions[extension]; } return null; } _getFormatAndFilterForFilename(fileName) { for (let extension in this._combinedExtensions) { if (fileName.endsWith(`.${extension}`)) { return this._combinedExtensions[extension]; } } for (let extension in this._extensions) { if (fileName.endsWith(`.${extension}`)) { return this._extensions[extension]; } } return null; } fileIsCompressed(fileName) { return this._getFormatAndFilterForFilename(fileName) !== null; } runToolAsync(autoArTool, cancellable) { return new Promise((resolve, reject) => { const connections = []; connections.push(autoArTool.connect('cancelled', () => { connections.forEach(c => autoArTool.disconnect(c)); reject(new GLib.Error(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED, 'Operation was cancelled')); })); connections.push(autoArTool.connect('error', (w, error) => { connections.forEach(c => autoArTool.disconnect(c)); reject(error); })); connections.push(autoArTool.connect('completed', () => { connections.forEach(c => autoArTool.disconnect(c)); resolve(); })); autoArTool.start_async(cancellable); }); } extractFile(fileName) { if (!this.checkAutoAr()) { return; } const fullPath = GLib.build_filenamev([GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_DESKTOP), fileName]); const formatFilter = this._getFormatAndFilterForFilename(fileName); const extSize = formatFilter.extension.length; const total = fullPath.length; const folderName = fullPath.substring(0, total - extSize); const folder = Gio.File.new_for_path(folderName); const doExtract = new progressDialog(this, _('Extracting files')); this._password = null; doExtract.doExtractFile(fullPath, folder, folderName).catch( e => logError(e)); } compressFileItems(fileList, destinationFolder) { if (!this.checkAutoAr()) { return; } new CompressDialog(this._desktopManager, fileList, destinationFolder); } compressFiles(fileList, outputFile, format, filter, password = null) { if (!this.checkAutoAr()) { return; } const doCompress = new progressDialog(this, _('Compressing files')); doCompress.doCompressFiles(fileList, outputFile, format, filter, password).catch( e => logError(e)); } notify(title, text) { this._desktopManager.dbusManager.doNotify(title, text); } getProgressElements() { return this._progressContainer.get_children(); } addProgress(progressElement, message) { this._progressContainer.pack_start(progressElement, false, true, 0); if (this._progressElements == 0) { this._inhibitCookie = this._desktopManager.mainApp.inhibit(null, Gtk.ApplicationInhibitFlags.LOGOUT | Gtk.ApplicationInhibitFlags.SUSPEND, message); } this._progressElements++; this._progressWindow.show_all(); this._progressWindow.present(); this.emit('progress-elements-changed', this._progressElements); } }; Signals.addSignalMethods(AutoAr.prototype); const progressDialog = class { constructor(autoArClass, message) { this._autoAr = autoArClass; this._waitingForPassword = false; this._currentPassword = null; this._buttonPromiseAccept = null; this._container = new Gtk.Box({ spacing: 0, halign: Gtk.Align.END, orientation: Gtk.Orientation.VERTICAL, }); this._processLabel = new Gtk.Label(); this._processBar = new Gtk.ProgressBar(); const container2 = new Gtk.Box({ spacing: 12, margin_top: 15, margin_bottom: 15, margin_start: 30, margin_end: 30, halign: Gtk.Align.CENTER, orientation: Gtk.Orientation.HORIZONTAL, }); const container3 = new Gtk.Box({ spacing: 10, halign: Gtk.Align.END, orientation: Gtk.Orientation.VERTICAL, }); this._cancelButton = new Gtk.Button({label: _('Cancel')}); this._cancelButton.connect('clicked', () => { if (this._buttonPromiseAccept) { this._buttonPromiseAccept(false); return; } this._cancellable.cancel(); }); this._passOkButton = new Gtk.Button({label: _('OK')}); this._passOkButton.get_style_context().add_class('suggested-action'); const passOKfunc = function () { this._processBar.show(); this._passEntry.hide(); this._passOkButton.hide(); this._currentPassword = this._passEntry.get_text(); if (this._buttonPromiseAccept) { this._buttonPromiseAccept(true); } }.bind(this); this._passOkButton.connect('clicked', passOKfunc); this._passEntry = new Gtk.Entry({ placeholder_text: _('Enter a password here'), input_purpose: Gtk.InputPurpose.PASSWORD, visibility: false, secondary_icon_name: 'view-conceal', secondary_icon_activatable: true, secondary_icon_sensitive: true, }); container3.pack_start(this._processLabel, false, true, 0); container3.pack_start(this._processBar, false, true, 0); container3.pack_start(this._passEntry, false, true, 0); container2.pack_start(container3, false, true, 0); container2.pack_start(this._passOkButton, false, false, 0); container2.pack_start(this._cancelButton, false, false, 0); this._container.pack_start(container2, false, false, 0); this._passEntry.connect('icon-release', () => { this._passEntry.visibility = !this._passEntry.visibility; }); this._passEntry.connect('activate', passOKfunc); const separator = new Gtk.Separator({orientation: Gtk.Orientation.HORIZONTAL}); this._container.pack_start(separator, false, true, 4); const updateSeparatorVisibility = () => { const progressElements = this._autoAr.getProgressElements(); separator.visible = progressElements.length && this._container != progressElements[progressElements.length - 1]; }; updateSeparatorVisibility(); this._elementsChangedId = this._autoAr.connect('progress-elements-changed', updateSeparatorVisibility); this._cancellable = new Gio.Cancellable(); this._autoAr.addProgress(this._container, message); this._passEntry.hide(); this._passOkButton.hide(); } async _cleanupFile(file, cancellable) { if (!file.query_exists(null)) { return; } this._processBar.set_fraction(0); this._processLabel.set_label(_("Removing partial file '${outputFile}'").replace( '${outputFile}', file.get_basename())); this._removeTimer(); this._timer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 250, () => { this._processBar.pulse(); return true; }); try { await FileUtils.deleteFile(file, null, cancellable); } catch (e) { logError(e, `Failed to remove ${file.get_path()}: ${e.message}`); } finally { this._removeTimer(); } } async doExtractFile(fullPath, folder, folderName, counter = 1) { this._processLabel.set_label(_('Creating destination folder')); this._processBar.pulse(); try { await folder.make_directory_async_promise(GLib.PRIORITY_DEFAULT, this._cancellable); const info = new Gio.FileInfo(); info.set_attribute_uint32(Gio.FILE_ATTRIBUTE_UNIX_MODE, 0o700); try { await folder.set_attributes_async_promise(info, Gio.FileQueryInfoFlags.NONE, GLib.PRIORITY_DEFAULT, this._cancellable); } catch (e) { logError(e, `Failed to set attributes to ${folder.get_path()}`); } } catch (e) { if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) { this._destroy(); return; } if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS)) { const newFolder = Gio.File.new_for_path(`${folderName} (${counter})`); await this.doExtractFile(fullPath, newFolder, folderName, counter + 1); return; } throw e; } this._processLabel.set_label(_("Extracting files into '${outputPath}'").replace( '${outputPath}', folder.get_basename())); const fullPathFile = Gio.File.new_for_path(fullPath); const extractor = GnomeAutoar.Extractor.new(fullPathFile, folder); extractor.set_output_is_dest(true); if (extractor.set_passphrase && (this._currentPassword !== null)) { extractor.set_passphrase(this._currentPassword); } this._removeTimer(); this._timer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 250, () => { this._processBar.pulse(); return true; }); let progressTotal = -1; const progressID = extractor.connect('progress', (w, completedSize) => { this._removeTimer(); if (progressTotal <= 0) { progressTotal = extractor.get_total_size(); } if (progressTotal > 0) { this._processBar.set_fraction(completedSize / progressTotal); } }); try { await this._autoAr.runToolAsync(extractor, this._cancellable); this._autoAr.notify(_('Extraction completed'), _("Extracting '${fullPathFile}' has been completed.").replace( '${fullPathFile}', fullPathFile.get_basename())); } catch (e) { if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) { this._cancellable = new Gio.Cancellable(); await this._cleanupFile(folder, this._cancellable); this._autoAr.notify(_('Extraction cancelled'), _("Extracting '${fullPathFile}' has been cancelled by the user.").replace( '${fullPathFile}', fullPathFile.get_basename())); } else { if ((e.code == GnomeAutoar.PASSPHRASE_REQUIRED_ERRNO) && (e.domain == GnomeAutoar.Extractor.quark())) { this._waitingForPassword = true; this._processBar.hide(); this._passEntry.show(); this._passOkButton.show(); this._passOkButton.set_receives_default(true); const tmpfile = Gio.File.new_for_path(fullPath); this._processLabel.set_label(_('Passphrase required for ${filename}').replace('${filename}', tmpfile.get_basename())); } else { this._waitingForPassword = false; this._autoAr.notify(_('Error during extraction'), e.message); } await this._cleanupFile(folder, this._cancellable); } } finally { this._removeTimer(); extractor.disconnect(progressID); if (!this._waitingForPassword) { this._destroy(); } } if (this._waitingForPassword) { const retval = await this._waitButtons(); this._buttonPromiseAccept = null; this._waitingForPassword = false; if (retval) { await this.doExtractFile(fullPath, folder, folderName); } } } _waitButtons() { return new Promise(accept => { this._buttonPromiseAccept = accept; }); } async doCompressFiles(fileList, outputFile, format, filter, password = null) { const output = Gio.File.new_for_path(outputFile); this._processLabel.set_label(_("Compressing files into '${outputFile}'").replace( '${outputFile}', output.get_basename())); const compressor = GnomeAutoar.Compressor.new(fileList, output, format, filter, false); compressor.set_output_is_dest(true); if (password) { compressor.set_passphrase(password); } const progressID = compressor.connect('progress', () => this._processBar.pulse()); try { await this._autoAr.runToolAsync(compressor, this._cancellable); this._autoAr.notify(_('Compression completed'), _("Compressing files into '${outputFile}' has been completed.").replace( '${outputFile}', output.get_basename())); } catch (e) { if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS)) { this._autoAr.notify(_('Cancelled compression'), _("The output file '${outputFile}' already exists.").replace( '${outputFile}', output.get_basename())); } else { this._cancellable = new Gio.Cancellable(); await this._cleanupFile(output, this._cancellable); if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) { this._autoAr.notify(_('Cancelled compression'), _("Compressing files into '${outputFile}' has been cancelled by the user.").replace( '${outputFile}', output.get_basename())); } else { this._autoAr.notify(_('Error during compression'), e.message); } } } finally { compressor.disconnect(progressID); this._destroy(); } } _removeTimer() { if (this._timer) { GLib.source_remove(this._timer); this._timer = 0; } } _destroy() { this._autoAr.disconnect(this._elementsChangedId); this._cancellable.cancel(); this._container.destroy(); } }; const CompressDialog = class { constructor(desktopManager, fileList, destinationFolder) { this._fileList = []; for (let file of fileList) { this._fileList.push(file.file); } this._desktopManager = desktopManager; this._destinationFolder = destinationFolder; this._dialog = new Gtk.Dialog({ title: _('Create archive'), resizable: false, modal: true, use_header_bar: true, default_width: 500, default_height: 210, window_position: Gtk.WindowPosition.CENTER_ALWAYS, }); const container = this._dialog.get_content_area(); container.orientation = Gtk.Orientation.VERTICAL; container.margin_top = 30; container.margin_bottom = 30; container.margin_start = 30; container.margin_end = 30; container.width_request = 390; container.halign = Gtk.Align.CENTER; container.spacing = 6; if (Prefs.nautilusCompression) { this._selectedType = Prefs.nautilusCompression.get_enum('default-compression-format'); } else { this._selectedType = Enums.CompressionType.ZIP; } const archiveLabel = new Gtk.Label({ label: `${_('Archive name')}`, xalign: 0, use_markup: true, }); container.pack_start(archiveLabel, false, true, 0); const box1 = new Gtk.Box({ spacing: 12, orientation: Gtk.Orientation.HORIZONTAL, }); this._nameEntry = new Gtk.Entry({ hexpand: true, width_chars: 30, }); this._extensionDropdown = new Gtk.Button(); const extensionContainer = new Gtk.Box({ spacing: 2, orientation: Gtk.Orientation.HORIZONTAL, }); this._extensionLabel = new Gtk.Label(); this._extensionLock = new Gtk.Image({icon_name: 'dialog-password'}); extensionContainer.pack_start(this._extensionLabel, false, false, 0); extensionContainer.pack_start(this._extensionLock, false, false, 5); this._extensionDropdown.add(extensionContainer); this._extensionPopover = new Gtk.Popover({ relative_to: this._extensionDropdown, border_width: 8, }); this._extensionPopoverContainer = new Gtk.Box({ spacing: 4, orientation: Gtk.Orientation.VERTICAL, }); this._extensionPopover.add(this._extensionPopoverContainer); this._passLabel = new Gtk.Label({ label: _('Password'), margin_top: 6, xalign: 0, }); this._passEntry = new Gtk.Entry({ placeholder_text: _('Enter a password here'), input_purpose: Gtk.InputPurpose.PASSWORD, visibility: false, secondary_icon_name: 'view-conceal', secondary_icon_activatable: true, secondary_icon_sensitive: true, }); container.pack_start(box1, false, true, 0); box1.pack_start(this._nameEntry, false, true, 0); box1.pack_start(this._extensionDropdown, false, false, 0); container.pack_start(this._passLabel, false, false, 0); container.pack_start(this._passEntry, false, false, 0); this._okButton = this._dialog.add_button(_('Create'), Gtk.ResponseType.ACCEPT); this._okButton.get_style_context().add_class('suggested-action'); this._okButton.set_receives_default(true); this._cancelButton = this._dialog.add_button(_('Cancel'), Gtk.ResponseType.CANCEL); this._cancelButton.set_receives_default(true); this._fillComboBox(); this._dialog.show_all(); this._updateStatus(); this._extensionDropdown.connect('clicked', () => { this._extensionPopoverContainer.show_all(); this._extensionPopover.popup(); for (let index in this._compressOptions) { const data = this._compressOptions[index]; data.selected_icon.visible = index == this._selectedType; } }); this._nameEntry.connect('changed', () => this._updateStatus()); this._passEntry.connect('changed', () => this._updateStatus()); this._nameEntry.connect('activate', () => this._entryActivated()); this._passEntry.connect('activate', () => this._entryActivated()); this._passEntry.connect('icon-release', () => { this._passEntry.visibility = !this._passEntry.visibility; }); this._dialog.connect('response', (dialog, id) => { if (id === Gtk.ResponseType.ACCEPT) { const data = this._desktopManager.autoAr.getFormatAndFilterForExtension(this._compressOptions[this._selectedType].extension); const outputFile = GLib.build_filenamev([this._destinationFolder, this._nameEntry.get_text() + data.extension]); const password = this._passEntry.get_text(); this._desktopManager.autoAr.compressFiles(this._fileList, outputFile, data.format, data.filter, password); } this._dialog.close(); }); } _entryActivated() { this._updateStatus(); if (this._okButton.sensitive) { this._dialog.response(Gtk.ResponseType.ACCEPT); } } _updateStatus() { if (Prefs.nautilusCompression) { Prefs.nautilusCompression.set_enum('default-compression-format', this._selectedType); } const label = this._compressOptions[this._selectedType].extension; this._extensionLabel.label = label; this._extensionLock.visible = this._compressOptions[this._selectedType].password; const password = this._compressOptions[this._selectedType].password; const outputfile = this._nameEntry.get_text() + label; this._passLabel.visible = password; this._passEntry.visible = password; let context = this._nameEntry.get_style_context(); this._okButton.sensitive = true; if (this._desktopManager._fileList.map(f => f.fileName).includes(outputfile)) { this._okButton.sensitive = false; if (!context.has_class('not-found')) { context.add_class('not-found'); } } else if (context.has_class('not-found')) { context.remove_class('not-found'); } if (password && (this._passEntry.get_text_length() == 0)) { this._okButton.sensitive = false; } if (this._nameEntry.get_text_length() == 0) { this._okButton.sensitive = false; } } _fillComboBox() { this._compressOptions = {}; this._addComboEntry(Enums.CompressionType.ZIP, { extension: '.zip', id: 'zip', description: _('Compatible with all operating systems.'), password: false, }); this._addComboEntry(Enums.CompressionType.ENCRYPTED_ZIP, { extension: '.zip', id: 'encryptedzip', description: _('Password protected .zip, must be installed on Windows and Mac.'), password: true, }); this._addComboEntry(Enums.CompressionType.TAR_XZ, { extension: '.tar.xz', id: 'tar.xz', description: _('Smaller archives but Linux and Mac only.'), password: false, }); this._addComboEntry(Enums.CompressionType.SEVEN_ZIP, { extension: '.7z', id: '7z', description: _('Smaller archives but must be installed on Windows and Mac.'), password: false, }); } _addComboEntry(type, data) { this._compressOptions[type] = data; if (!this._desktopManager.autoAr.extensionIsAvailable(data.extension)) { return; } const container = new Gtk.Box({orientation: Gtk.Orientation.VERTICAL}); const container2 = new Gtk.Box({orientation: Gtk.Orientation.HORIZONTAL}); const container3 = new Gtk.Box({orientation: Gtk.Orientation.HORIZONTAL}); container3.pack_start(new Gtk.Label({ label: data.extension, justify: Gtk.Justification.LEFT, xalign: 0, }), false, false, 0); if (data.password) { container3.pack_start(new Gtk.Image({icon_name: 'dialog-password'}), false, false, 5); } container.pack_start(container3, false, false, 0); container.pack_start(new Gtk.Label({ label: data.description, justify: Gtk.Justification.LEFT, xalign: 0, }), false, false, 0); const button = new Gtk.Button(); container2.pack_start(container, true, true, 0); data.selected_icon = new Gtk.Image({icon_name: 'emblem-default'}); container2.pack_start(data.selected_icon, false, false, 0); button.add(container2); this._extensionPopoverContainer.pack_start(button, false, true, 0); button.connect('clicked', () => { this._selectedType = type; this._extensionPopover.popdown(); this._updateStatus(); }); } };