mirror of
https://github.com/edu4rdshl/Strata.git
synced 2026-07-18 07:34:45 +00:00
Audit fixes for the extension: - Track and disconnect the changed::excluded-apps GSettings handler in disable() (it was the one connection whose id was never captured). - Track the _stopDaemon 1.5s force-kill timer and cancel any leftover on enable(), so the source can't outlive a disable->enable cycle. - Guard the exclusion-path delete with _proxy?.DeleteItemAsync. - Mirror the daemon's image allowlist (drop avif/svg) in _pickMime. - Show a generic "Image" label for all image rows instead of singling out PNG; the thumbnail identifies the image and the on-clipboard format (often PNG even for a copied GIF/WebP) is an implementation detail. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
267 lines
9.3 KiB
JavaScript
267 lines
9.3 KiB
JavaScript
/* clipboardItem.js - row widget for the clipboard list. */
|
|
|
|
import GObject from 'gi://GObject';
|
|
import GLib from 'gi://GLib';
|
|
import St from 'gi://St';
|
|
import Clutter from 'gi://Clutter';
|
|
import Gio from 'gi://Gio';
|
|
|
|
const TEXT_PREVIEW_LEN = 140;
|
|
const THUMB_SIZE = 48;
|
|
|
|
const ICON_BY_MIME = {
|
|
'text/uri-list': 'emblem-web-symbolic',
|
|
'text/html': 'text-html-symbolic',
|
|
'image/': 'image-x-generic-symbolic',
|
|
};
|
|
|
|
function iconForMime(mimeType) {
|
|
for (const [prefix, icon] of Object.entries(ICON_BY_MIME)) {
|
|
if (mimeType.startsWith(prefix)) return icon;
|
|
}
|
|
return 'edit-copy-symbolic';
|
|
}
|
|
|
|
/** Detect if a string looks like a URL. */
|
|
function isUrl(text) {
|
|
return /^https?:\/\/.+/i.test(text.trim());
|
|
}
|
|
|
|
/** Detect if a string looks like a CSS/hex color. */
|
|
function isColor(text) {
|
|
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(text.trim());
|
|
}
|
|
|
|
export const ClipboardItem = GObject.registerClass({
|
|
Signals: {
|
|
'activate': {},
|
|
'delete': {},
|
|
},
|
|
}, class ClipboardItem extends St.Button {
|
|
_init(id, mimeType, preview, opts = {}) {
|
|
super._init({
|
|
style_class: 'strata-item',
|
|
x_expand: true,
|
|
can_focus: true,
|
|
reactive: true,
|
|
});
|
|
|
|
this._id = id;
|
|
this._mimeType = mimeType;
|
|
/** D-Bus proxy used for lazy thumbnail fetch. */
|
|
this._proxy = opts.proxy ?? null;
|
|
/** Optional shared LRU cache (Map<id, string filePath>) - populated on first fetch. */
|
|
this._thumbCache = opts.thumbCache ?? null;
|
|
/** Stored on the actor so panel.js can filter by it. */
|
|
this.actor = this;
|
|
this._strataPreview = mimeType.startsWith('image/') ? '' : preview;
|
|
|
|
const row = new St.BoxLayout({
|
|
style_class: 'strata-item-row',
|
|
x_expand: true,
|
|
});
|
|
|
|
// Left: icon or image thumbnail
|
|
row.add_child(this._buildLeading(mimeType, preview));
|
|
|
|
// Center: text preview
|
|
row.add_child(this._buildContent(mimeType, preview));
|
|
|
|
// Right: delete button
|
|
const deleteBtn = new St.Button({
|
|
style_class: 'strata-item-delete',
|
|
icon_name: 'edit-delete-symbolic',
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
});
|
|
deleteBtn.connect('clicked', () => {
|
|
this.emit('delete');
|
|
return Clutter.EVENT_STOP;
|
|
});
|
|
row.add_child(deleteBtn);
|
|
|
|
this.set_child(row);
|
|
this.connect('clicked', () => this.emit('activate'));
|
|
|
|
// Make the item text bold when keyboard-focused so the selected item
|
|
// is immediately obvious during arrow-key navigation.
|
|
// We add/remove an explicit style class because CSS :focus pseudo-class
|
|
// can be unreliable in GNOME Shell extensions. The bold + focus text
|
|
// color live in CSS (.strata-item-focused .strata-item-text) so each
|
|
// theme (dark/light) can color them; we only toggle the class here.
|
|
this.connect('key-focus-in', () => {
|
|
this.add_style_class_name('strata-item-focused');
|
|
});
|
|
this.connect('key-focus-out', () => {
|
|
this.remove_style_class_name('strata-item-focused');
|
|
});
|
|
}
|
|
|
|
_buildLeading(mimeType, preview) {
|
|
if (mimeType.startsWith('image/')) {
|
|
return this._buildThumbnail(this._id);
|
|
}
|
|
if (isColor(preview)) {
|
|
return this._buildColorSwatch(preview);
|
|
}
|
|
const icon = new St.Icon({
|
|
icon_name: iconForMime(mimeType),
|
|
icon_size: 20,
|
|
style_class: 'strata-item-icon',
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
});
|
|
return icon;
|
|
}
|
|
|
|
/** Build an image thumbnail container.
|
|
* - If already in shared cache, apply immediately.
|
|
* - Else fetch via GetThumbnailRemote, write to ~/.cache/strata/thumbnails/{id}.png,
|
|
* then apply. The decode happens off-thread (CSS background-image loader). */
|
|
_buildThumbnail(id) {
|
|
const container = new St.Widget({
|
|
width: THUMB_SIZE,
|
|
height: THUMB_SIZE,
|
|
style_class: 'strata-item-thumb',
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
});
|
|
|
|
const cacheDir = `${GLib.get_user_cache_dir()}/strata/thumbnails`;
|
|
const cachePath = `${cacheDir}/${id}.png`;
|
|
const fileUri = `file://${cachePath}`;
|
|
|
|
const applyStyle = () => {
|
|
try {
|
|
container.style = `background-image: url("${fileUri}"); background-size: cover; background-repeat: no-repeat;`;
|
|
} catch (_) { /* container was destroyed mid-flight */ }
|
|
};
|
|
|
|
try {
|
|
// Fast path: already cached this session.
|
|
if (this._thumbCache?.has(id)) {
|
|
applyStyle();
|
|
return container;
|
|
}
|
|
// Second fast path: file exists on disk from a previous session.
|
|
if (GLib.file_test(cachePath, GLib.FileTest.EXISTS)) {
|
|
this._thumbCache?.set(id, cachePath);
|
|
applyStyle();
|
|
return container;
|
|
}
|
|
// Slow path: ask the daemon for the bytes, then write file async.
|
|
GLib.mkdir_with_parents(cacheDir, 0o755);
|
|
if (!this._proxy) {
|
|
this._fallbackIcon(container);
|
|
return container;
|
|
}
|
|
this._proxy.GetThumbnailRemote(id, ([bytes]) => {
|
|
try {
|
|
if (!container.get_parent()) return; // destroyed
|
|
if (!bytes || bytes.length === 0) {
|
|
this._fallbackIcon(container);
|
|
return;
|
|
}
|
|
const file = Gio.File.new_for_path(cachePath);
|
|
file.replace_contents_bytes_async(
|
|
new GLib.Bytes(bytes),
|
|
null,
|
|
false,
|
|
Gio.FileCreateFlags.NONE,
|
|
null,
|
|
(_f, result) => {
|
|
try {
|
|
_f.replace_contents_finish(result);
|
|
this._thumbCache?.set(id, cachePath);
|
|
applyStyle();
|
|
} catch (e) {
|
|
console.error('[Strata] Thumbnail write error:', e);
|
|
this._fallbackIcon(container);
|
|
}
|
|
}
|
|
);
|
|
} catch (e) {
|
|
console.error('[Strata] Thumbnail fetch handler error:', e);
|
|
this._fallbackIcon(container);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error('[Strata] Thumbnail render error:', e);
|
|
this._fallbackIcon(container);
|
|
}
|
|
return container;
|
|
}
|
|
|
|
_fallbackIcon(container) {
|
|
if (container.get_n_children() > 0) return;
|
|
try {
|
|
const icon = new St.Icon({
|
|
icon_name: 'image-x-generic-symbolic',
|
|
icon_size: 20,
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
x_align: Clutter.ActorAlign.CENTER,
|
|
});
|
|
container.add_child(icon);
|
|
} catch (_) {}
|
|
}
|
|
|
|
_buildColorSwatch(hex) {
|
|
return new St.Widget({
|
|
width: 24,
|
|
height: 24,
|
|
style: `background-color: ${hex}; border-radius: 4px; border: 1px solid rgba(0,0,0,0.2);`,
|
|
style_class: 'strata-item-swatch',
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
});
|
|
}
|
|
|
|
_buildContent(mimeType, preview) {
|
|
preview = preview ?? '';
|
|
const box = new St.BoxLayout({
|
|
vertical: true,
|
|
x_expand: true,
|
|
style_class: 'strata-item-content',
|
|
});
|
|
|
|
let mainText = '';
|
|
let subText = '';
|
|
|
|
if (mimeType.startsWith('image/')) {
|
|
// Generic label - the thumbnail identifies the image, and the
|
|
// on-clipboard format (often PNG even for a copied GIF/WebP) is an
|
|
// implementation detail that misleads more than it informs.
|
|
mainText = 'Image';
|
|
} else if (isUrl(preview)) {
|
|
mainText = preview.trim();
|
|
try {
|
|
subText = new URL(preview.trim()).hostname;
|
|
} catch (_) {}
|
|
} else if (isColor(preview)) {
|
|
mainText = preview.trim().toUpperCase();
|
|
subText = 'Color';
|
|
} else {
|
|
const trimmed = preview.replace(/\s+/g, ' ').trim();
|
|
mainText = trimmed.length > TEXT_PREVIEW_LEN
|
|
? trimmed.slice(0, TEXT_PREVIEW_LEN) + '…'
|
|
: trimmed;
|
|
}
|
|
|
|
const labelMain = new St.Label({
|
|
text: mainText || '(empty)',
|
|
style_class: `strata-item-text${isUrl(preview) ? ' strata-item-url' : ''}`,
|
|
x_expand: true,
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
});
|
|
labelMain.clutter_text.line_wrap = false;
|
|
labelMain.clutter_text.ellipsize = 3; // PANGO_ELLIPSIZE_END
|
|
box.add_child(labelMain);
|
|
|
|
if (subText) {
|
|
const labelSub = new St.Label({
|
|
text: subText,
|
|
style_class: 'strata-item-subtext',
|
|
x_expand: true,
|
|
});
|
|
box.add_child(labelSub);
|
|
}
|
|
|
|
return box;
|
|
}
|
|
});
|