Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions workspace-icons@yoy675/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Workspace Icons

workspace-icons@yoy675 — Switch desktop folders and icons when changing Cinnamon workspaces.

## Description

This Cinnamon extension lets you present a different Desktop folder (and therefore different desktop icons) per workspace. When changing workspaces, the extension updates the XDG Desktop directory to point to a workspace-specific folder, and refreshes the Cinnamon desktop to show the icons from that folder.

## Features

- Per-workspace Desktop folder handling (creates ~/Desktop/workspace0, workspace1, ...)
- Automatically creates all workspace directories on extension enable
- Copies existing Desktop contents into workspace folders on first use to give each workspace a starting set of icons
- Restores/merges workspace contents back into ~/Desktop when the extension is disabled
- Refreshes desktop icons when workspace changes

## Requirements

- Cinnamon desktop (tested on Cinnamon 5.x and 6.x)
- GLib/Gio provided by the Cinnamon runtime
- `xdg-user-dirs-update` package (usually pre-installed on most distributions)
- Nemo desktop support (Cinnamon's default file manager)

## Installation

1. Copy the extension folder into your local Cinnamon extensions directory:

```bash
mkdir -p ~/.local/share/cinnamon/extensions
cp -r "workspace-icons@yoy675" ~/.local/share/cinnamon/extensions/
```

2. Restart Cinnamon (log out and in, or run):

```bash
cinnamon --replace &
```

3. Enable the extension using the Extensions settings panel in Cinnamon.

## Configuration

This extension does not currently expose a graphical preferences dialog. Configuration is simple filesystem-based behavior:

- Per-workspace folders are automatically created under `~/Desktop` as `workspace0`, `workspace1`, etc.
- Each workspace shows the contents of its corresponding folder
- If you want different initial icons per workspace, seed those folders with files or symlinks before switching to that workspace

## Usage

1. After installation and enabling, switch between workspaces
2. Observe that the Desktop icons change to the workspace-specific folder contents
3. Add or remove files from the `~/Desktop/workspaceN` folders to customize icons per workspace
4. When disabling the extension, it will merge files back from each `workspaceN` folder into `~/Desktop` and remove the workspace folders

## Known Issues

- File conflicts: If the same filename exists in multiple workspace folders, the extension attempts to overwrite by default when disabling. Back up important files in `~/Desktop/workspaceN` folders before disabling.
- Desktop refresh: The extension changes the XDG Desktop directory using `xdg-user-dirs-update`. Some distributions may handle this differently; test carefully on your system.
- This extension is currently Cinnamon-specific and only refreshes Nemo desktop icons.

## Security

This extension has been updated to use secure command execution methods to prevent command injection vulnerabilities. All external commands are spawned using GLib.spawn_async with argument arrays rather than shell command strings.

## Changelog

- 1.0 — Initial submission
- Fixed command injection vulnerability by using GLib.spawn_async with argument arrays
- Pre-creates all workspace directories on extension enable using gsettings
- Scoped extension to Cinnamon-specific operations
- Improved error handling and logging

## Credits

Author: yoy675 — https://github.com/yoy675

## License

This project is licensed under AGPL-3.0. See the top-level LICENSE file for details.
310 changes: 310 additions & 0 deletions workspace-icons@yoy675/files/workspace-icons@yoy675/extension.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
const GLib = imports.gi.GLib;
const Gio = imports.gi.Gio;
const Main = imports.ui.main;
const Mainloop = imports.mainloop;

class WorkspaceDesktopExtension {
constructor() {
this._workspaceManager = global.screen;
this._signalId = null;
this._gsettings = null;
}

enable() {
log('Workspace Desktop extension enabled');

try {
// Initialize GSettings for workspace configuration
this._gsettings = new Gio.Settings({
schema_id: 'org.cinnamon.desktop.wm.preferences'
});

// Pre-create workspace directories based on num-workspaces setting
this._preCreateWorkspaceDirectories();
} catch (e) {
logError(e, 'Failed to initialize GSettings');
}

// Listen for workspace changes
this._signalId = this._workspaceManager.connect(
'workspace-switched',
this._onWorkspaceChanged.bind(this)
);

// Run on initial workspace
this._onWorkspaceChanged();
}

disable() {
log('Workspace Desktop extension disabling: merging files back to ~/Desktop...');
if (this._signalId) {
this._workspaceManager.disconnect(this._signalId);
this._signalId = null;
}
if (this._gsettings) {
this._gsettings = null;
}
// Merge workspace subfolders back into main ~/Desktop and clean up
this._mergeWorkspacesBack();
log('Workspace Desktop extension disabled');
}

_preCreateWorkspaceDirectories() {
try {
// Get the number of workspaces from GSettings
const numWorkspaces = this._gsettings.get_int('num-workspaces');
const homeDir = GLib.get_home_dir();
const desktopBaseDir = `${homeDir}/Desktop`;

log(`Creating directories for ${numWorkspaces} workspaces`);

// Pre-create all workspace directories
for (let i = 0; i < numWorkspaces; i++) {
const workspaceDir = `${desktopBaseDir}/workspace${i}`;
this._ensureDirectoryExists(workspaceDir);

// Copy existing root Desktop files into the workspace folder on first creation
this._copyRootDesktopFilesToWorkspace(desktopBaseDir, workspaceDir);
}
} catch (e) {
logError(e, 'Error pre-creating workspace directories');
}
}

_onWorkspaceChanged() {
const activeWorkspace = this._workspaceManager.get_active_workspace();
const workspaceIndex = activeWorkspace.index();

log(`Workspace changed to: ${workspaceIndex}`);
this._runOnWorkspaceChange(workspaceIndex);
}

_runOnWorkspaceChange(workspaceIndex) {
try {
const homeDir = GLib.get_home_dir();
const desktopBaseDir = `${homeDir}/Desktop`;
const workspaceDir = `${desktopBaseDir}/workspace${workspaceIndex}`;

// 1. Ensure workspace directory exists (in case it was deleted)
this._ensureDirectoryExists(workspaceDir);

// 2. Update XDG Desktop directory target
this._updateXdgDesktopDir(workspaceDir);

// 3. Refresh desktop icons
this._refreshDesktopIcons();

} catch (e) {
logError(e, 'Error in workspace change handler');
}
}

_ensureDirectoryExists(path) {
const file = Gio.file_new_for_path(path);
if (!file.query_exists(null)) {
try {
file.make_directory_with_parents(null);
log(`Created directory: ${path}`);
} catch (e) {
logError(e, `Failed to create directory: ${path}`);
}
}
}

_copyRootDesktopFilesToWorkspace(desktopPath, workspacePath) {
const desktopDir = Gio.File.new_for_path(desktopPath);
const wsDir = Gio.File.new_for_path(workspacePath);

if (!desktopDir.query_exists(null)) return;

try {
// Only copy if the workspace directory is empty (first time)
const enumerator = wsDir.enumerate_children(
'standard::*',
Gio.FileQueryInfoFlags.NONE,
null
);

let info = enumerator.next_file(null);
if (info !== null) {
// Directory is not empty, skip copying
log(`Workspace directory ${workspacePath} is not empty, skipping initial copy`);
return;
}
} catch (e) {
// If error checking, proceed with copy anyway
log(`Could not check if workspace directory is empty: ${e.message}`);
}

try {
const enumerator = desktopDir.enumerate_children(
'standard::*',
Gio.FileQueryInfoFlags.NONE,
null
);

let info;
while ((info = enumerator.next_file(null)) !== null) {
const name = info.get_name();

// Skip workspace folders to avoid recursive copying
if (name.match(/^workspace\d+$/)) continue;

const srcFile = desktopDir.get_child(name);
const destFile = wsDir.get_child(name);

// Copy only if file/folder doesn't already exist in the target workspace
if (!destFile.query_exists(null)) {
try {
srcFile.copy(destFile, Gio.FileCopyFlags.NONE, null, null);
log(`Copied ${name} -> ${workspacePath}`);
} catch (err) {
logError(err, `Failed copying ${name} to workspace`);
}
}
}
} catch (e) {
logError(e, 'Error reading root Desktop folder');
}
}

_mergeWorkspacesBack() {
const homeDir = GLib.get_home_dir();
const mainDesktopPath = `${homeDir}/Desktop`;
const desktopDir = Gio.File.new_for_path(mainDesktopPath);

if (desktopDir.query_exists(null)) {
try {
const enumerator = desktopDir.enumerate_children(
'standard::*',
Gio.FileQueryInfoFlags.NONE,
null
);

let info;
while ((info = enumerator.next_file(null)) !== null) {
const name = info.get_name();

// Find workspace directories
if (name.match(/^workspace\d+$/)) {
const wsDir = desktopDir.get_child(name);

// Move contents out of workspace folder into ~/Desktop
this._emptyDirectoryToDestination(wsDir, desktopDir);

// Delete the now-empty workspace folder
try {
wsDir.delete(null);
log(`Deleted directory: ${name}`);
} catch (err) {
logError(err, `Could not remove folder ${name}`);
}
}
}
} catch (e) {
logError(e, 'Error restoring workspace files to ~/Desktop');
}
}

// Reset XDG user directory back to original ~/Desktop
this._updateXdgDesktopDir(mainDesktopPath);
this._refreshDesktopIcons();
}

_emptyDirectoryToDestination(srcDir, destDir) {
if (!srcDir.query_exists(null)) return;

try {
const enumerator = srcDir.enumerate_children(
'standard::*',
Gio.FileQueryInfoFlags.NONE,
null
);

let info;
while ((info = enumerator.next_file(null)) !== null) {
const name = info.get_name();
const srcFile = srcDir.get_child(name);
const destFile = destDir.get_child(name);

try {
// Overwrite standard files if duplicates exist, or skip if needed
srcFile.move(destFile, Gio.FileCopyFlags.OVERWRITE, null, null);
log(`Merged ${name} back to ~/Desktop`);
} catch (e) {
logError(e, `Failed to move ${name} back to ~/Desktop`);
}
}
} catch (e) {
logError(e, 'Error iterating directory contents during merge');
}
}

_updateXdgDesktopDir(path) {
try {
// SECURITY FIX: Use spawn_async with argument array instead of spawn_command_line_async
// to prevent command injection attacks
const argv = ['xdg-user-dirs-update', '--set', 'DESKTOP', path];
GLib.spawn_sync(
null, // working directory
argv, // argument array
null, // environment
GLib.SpawnFlags.SEARCH_PATH,
null // child setup
);
log(`Updated DESKTOP to: ${path}`);
} catch (e) {
logError(e, 'Failed to update XDG Desktop directory');
}
}

_refreshDesktopIcons() {
const desktopSession = GLib.getenv('XDG_CURRENT_DESKTOP') || 'Cinnamon';

try {
if (desktopSession.includes('Cinnamon')) {
// SECURITY FIX: Use spawn_async with argument array
this._spawnCommand(['nemo-desktop', '-q']);
Mainloop.timeout_add(100, () => {
this._spawnCommand(['nemo-desktop']);
return false;
});
}
} catch (e) {
logError(e, 'Failed to refresh desktop icons');
}
}

_spawnCommand(argv) {
try {
// SECURITY FIX: Use array-based spawning to avoid command injection
GLib.spawn_async(
null, // working directory
argv, // argument array (not a single command string)
null, // environment
GLib.SpawnFlags.SEARCH_PATH,
null // child setup
);
} catch (e) {
logError(e, `Failed to spawn command: ${argv.join(' ')}`);
}
}
}

let extension;

function init(metadata) {
// Required entry point called when extension is loaded
}

function enable() {
extension = new WorkspaceDesktopExtension();
extension.enable();
}

function disable() {
if (extension) {
extension.disable();
extension = null;
}
}
Loading
Loading