Strata/strata@edu4rdshl.dev/ui/clipboardItem.js
Eduard Tolosa 332ec4064a fix: extension lifecycle leaks, image allowlist, generic image label
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>
2026-05-26 17:29:37 -05:00

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;
}
});