fix: run spaces script just on settings change (#96)

* fix(hm-module,spaces): missing columns when Zen is opened before the spaces table is created

Appearently, opening Zen before the spaces service runs could cause the
table not having the theme columns. Because of this, the CREATE TABLE IF
NOT EXISTS query would not run, but the columns would still not be
present. This commit adds the column via a bash script plus an ALTER
TABLE query, mimicking more what Zen does in it JavaScript code.

* style(hm-module): run nix fmt on hm-module

* refactor(hm-module,spaces): move bash scripts and sql queries directly to service executable

This was made to reduce the amount of derivations being build to just
one per-profile. Also, this helps make all the logic of the spaces
activation be into a single executable for debugging and error handling.

* feat(hm-module,spaces): error handling for locked database

Provide a useful error message to the user if the service fails due to
opened Zen browser instance.

* feat(hm-module,spaces): implement places.sqlite updating via home.file.* script

This fixed the problem of the systemd service being rerun on every
home-manager rebuild. Now the places.sqlite updating script just runs
when something needs to be updated on it. We use home.file.* to store
the script on the home directory and take advantage of the
home.file.*.onChange option to run it.

* feat(hm-module,spaces)!: change type of colors values to integer between 0 and 255

* docs: change bold headers to markdown headers to add support #links

* docs: [work]spaces options documentation and example
This commit is contained in:
Guz
2025-08-22 17:06:40 -03:00
committed by GitHub
parent a1bb1b39be
commit 5e090cc936
2 changed files with 464 additions and 310 deletions

252
.github/README.md vendored
View File

@@ -7,10 +7,11 @@ This is a nix flake for the Zen browser.
- Linux and MacOS support
- Available for _x86_64_ and _aarch64_
- Support for _twilight_ and _beta_
- Policies can be modified via Home Manager and unwrapped package override
- [Policies can be modified via Home Manager and unwrapped package override](#policies)
- Fast & Automatic updates via GitHub Actions
- Browser update checks are disabled by default
- The default twilight version is reliable and reproducible
- [Declarative \[Work\]Spaces (including themes, icons, containers)](#spaces)
## Installation
@@ -123,94 +124,177 @@ experiment with other program options and help with further documentation.
}
```
### Policies
- `policies` (attrsOf anything): You can also modify the **extensions** and **preferences** from here.
**Some common policies:**
#### Some common policies
```nix
{
programs.zen-browser.policies = {
AutofillAddressEnabled = true;
AutofillCreditCardEnabled = false;
DisableAppUpdate = true;
DisableFeedbackCommands = true;
DisableFirefoxStudies = true;
DisablePocket = true;
DisableTelemetry = true;
DontCheckDefaultBrowser = true;
NoDefaultBookmarks = true;
OfferToSaveLogins = false;
EnableTrackingProtection = {
Value = true;
Locked = true;
Cryptomining = true;
Fingerprinting = true;
```nix
{
programs.zen-browser.policies = {
AutofillAddressEnabled = true;
AutofillCreditCardEnabled = false;
DisableAppUpdate = true;
DisableFeedbackCommands = true;
DisableFirefoxStudies = true;
DisablePocket = true;
DisableTelemetry = true;
DontCheckDefaultBrowser = true;
NoDefaultBookmarks = true;
OfferToSaveLogins = false;
EnableTrackingProtection = {
Value = true;
Locked = true;
Cryptomining = true;
Fingerprinting = true;
};
};
}
```
For more policies [read this](https://mozilla.github.io/policy-templates/).
#### Preferences
```nix
{
programs.zen-browser.policies = let
mkLockedAttrs = builtins.mapAttrs (_: value: {
Value = value;
Status = "locked";
});
in {
Preferences = mkLockedAttrs {
"browser.tabs.warnOnClose" = false;
# and so on...
};
};
}
```
##### Zen-specific preferences
Check [this comment](https://github.com/0xc000022070/zen-browser-flake/issues/59#issuecomment-2964607780).
#### Extensions
```nix
{
programs.zen-browser.policies = let
mkExtensionSettings = builtins.mapAttrs (_: pluginId: {
install_url = "https://addons.mozilla.org/firefox/downloads/latest/${pluginId}/latest.xpi";
installation_mode = "force_installed";
});
in {
ExtensionSettings = mkExtensionSettings {
"wappalyzer@crunchlabz.com" = "wappalyzer";
"{85860b32-02a8-431a-b2b1-40fbd64c9c69}" = "github-file-icons";
};
};
}
```
To setup your own extensions you should:
1. [Go to Add-ons for Firefox](https://addons.mozilla.org/en-US/firefox/).
2. Go to the page of the extension that you want to declare.
3. Go to "_See all versions_".
4. Copy the link from any button to "Download file".
5. Exec **wget** with the output of this command:
```bash
echo "<paste-the-link-here>" \
| sed -E 's|https://addons.mozilla.org/firefox/downloads/file/[0-9]+/([^/]+)-[^/]+\.xpi|\1|' \
| tr '_' '-' \
| awk '{print "https://addons.mozilla.org/firefox/downloads/latest/" $1 "/latest.xpi"}'
```
6. Run `unzip -*.xpi -d my-extension && cd my-extension`.
7. Run `cat manifest.json | jq -r '.browser_specific_settings.gecko.id'` and use the result
for the _entry key_.
8. Don't forget to add the `install_url` and set `installation_mode` to `force_installed`.
### Spaces
> [!WARNING]
> Spaces declaration may change your rebuild experience with Home Manager. Due to limitations
> on how Zen handles spaces, the updating of them is done via a activation script on your
> `home-manager-<user>.service`. This may cause the service to fail, to prevent this,
> it is recommended to close your Zen browser instance before rebuilding.
- `profiles.*.spaces` (attrsOf submodule): Declare profile's \[work\]spaces.
- `name` (string) Name of space, defaults to submodule/attribute name.
- `id` (string) **Required.** UUID v4 of space. **Changing this after a rebuild will re-create the space as
a new one,** losing opened tabs, groups, etc. If `spacesForce` is true, the space with the previous UUID will be deleted.
- `position` (unsigned integer) Position/order of space in the left bar.
- `icon` (null or (string or path)) Emoji, URI or file path for icon to be used as space icon.
- `container` (null or unsigned integer) Container ID to be used as default in space.
- `theme.type` (nullOr string) Type of theme, defaults to "gradient".
- `theme.color` (listOf submodule) List of JSON colors to be used as theme:
- `red` (integer between 0 and 255) Red value of color (first value of "c" array in JSON object).
- `green` (integer between 0 and 255) Green value of color (second value of "c" array in JSON object).
- `blue` (integer between 0 and 255) Blue value of color (third value of "c" array in JSON object).
- `custom` (boolean) Is custom color ("isCustom" in JSON object).
- `algorithm` (enum of "complementary", "floating" or "analogous") color algorithm (defaults to "floating").
- `lightness` (integer) Lightness of color.
- `position.x` (integer) X Position of color in gradient picker on Zen browser.
- `position.y` (integer) Y Position of color in gradient picker on Zen browser.
- `type` (enum of "undefined" or "explicit-lightness") Type of color (default to "undefined").
- `theme.opacity` (null or float) Opacity of theme (defaults to 0.5).
- `theme.rotation` (null or integer) Rotation of theme gradient (defaults to null).
- `theme.texture` (null or float) Amount of texture of theme (defaults to 0.0).
- `profiles.*.spacesForce` (boolean) Whether to delete existing spaces not declared in the configuration.
Recommended to make spaces fully declarative (defaults to false).
```nix
{
programs.zen-browser = {
enable = true;
profiles."default" = {
containersForce = true;
containers = {
Personal = {
color = "purple";
icon = "fingerprint";
id = 1;
};
Work = {
color = "blue";
icon = "briefcase";
id = 2;
};
Shopping = {
color = "yellow";
icon = "dollarsign";
id = 3;
};
};
spacesForce = true;
spaces = let
containers = config.programs.zen-browser.profiles."default".containers;
in {
"Space" = {
id = "c6de089c-410d-4206-961d-ab11f988d40a";
position = 1000;
};
"Work" = {
id = "cdd10fab-4fc5-494b-9041-325e5759195b";
icon = "chrome://browser/skin/zen-icons/selectable/star-2.svg";
container = containers."Work".id;
position = 2000;
};
"Shopping" = {
id = "78aabdad-8aae-4fe0-8ff0-2a0c6c4ccc24";
icon = "💸";
container = containers."Shopping".id;
position = 3000;
};
};
};
}
```
For more policies [read this](https://mozilla.github.io/policy-templates/).
**Preferences:**
```nix
{
programs.zen-browser.policies = let
mkLockedAttrs = builtins.mapAttrs (_: value: {
Value = value;
Status = "locked";
});
in {
Preferences = mkLockedAttrs {
"browser.tabs.warnOnClose" = false;
# and so on...
};
};
}
```
**Zen-specific preferences:**
Check [this comment](https://github.com/0xc000022070/zen-browser-flake/issues/59#issuecomment-2964607780).
**Extensions:**
```nix
{
programs.zen-browser.policies = let
mkExtensionSettings = builtins.mapAttrs (_: pluginId: {
install_url = "https://addons.mozilla.org/firefox/downloads/latest/${pluginId}/latest.xpi";
installation_mode = "force_installed";
});
in {
ExtensionSettings = mkExtensionSettings {
"wappalyzer@crunchlabz.com" = "wappalyzer";
"{85860b32-02a8-431a-b2b1-40fbd64c9c69}" = "github-file-icons";
};
};
}
```
To setup your own extensions you should:
1. [Go to Add-ons for Firefox](https://addons.mozilla.org/en-US/firefox/).
2. Go to the page of the extension that you want to declare.
3. Go to "_See all versions_".
4. Copy the link from any button to "Download file".
5. Exec **wget** with the output of this command:
```bash
echo "<paste-the-link-here>" \
| sed -E 's|https://addons.mozilla.org/firefox/downloads/file/[0-9]+/([^/]+)-[^/]+\.xpi|\1|' \
| tr '_' '-' \
| awk '{print "https://addons.mozilla.org/firefox/downloads/latest/" $1 "/latest.xpi"}'
```
6. Run `unzip -*.xpi -d my-extension && cd my-extension`.
7. Run `cat manifest.json | jq -r '.browser_specific_settings.gecko.id'` and use the result
for the _entry key_.
8. Don't forget to add the `install_url` and set `installation_mode` to `force_installed`.
};
}
```
## 1Password

View File

@@ -29,11 +29,13 @@
linuxConfigPath = ".zen";
darwinConfigPath = "Library/Application Support/Zen";
configPath = "${config.home.homeDirectory}/${(
if pkgs.stdenv.isDarwin
then darwinConfigPath
else linuxConfigPath
)}";
configPath = "${
(
if pkgs.stdenv.isDarwin
then darwinConfigPath
else linuxConfigPath
)
}";
mkFirefoxModule = import "${home-manager.outPath}/modules/programs/firefox/mkFirefoxModule.nix";
in {
@@ -59,113 +61,134 @@ in {
options = setAttrByPath modulePath {
profiles = mkOption {
type = with types;
attrsOf (submodule ({...}: {
options = {
spacesForce = mkOption {
type = bool;
description = "Whether delete existing spaces not declared in the configuration.";
default = false;
};
spaces = mkOption {
type = attrsOf (submodule ({name, ...}: {
options = {
name = mkOption {
type = str;
description = "Name of the space.";
default = name;
};
id = mkOption {
type = str;
description = "REQUIRED. Unique Version 4 UUID for space.";
};
position = mkOption {
type = ints.unsigned;
description = "Position of space in the left bar.";
default = 1000;
};
icon = mkOption {
type = nullOr (either str path);
description = "Emoji or icon URI to be used as space icon.";
apply = v:
if isPath v
then "file://${v}"
else v;
default = null;
};
container = mkOption {
type = nullOr ints.unsigned;
description = "Container ID to be used in space";
default = null;
};
theme.type = mkOption {
type = nullOr str;
default = "gradient";
};
theme.colors = mkOption {
type = nullOr (listOf (submodule ({...}: {
options = {
red = mkOption {
type = int;
default = 0;
};
green = mkOption {
type = int;
default = 0;
};
blue = mkOption {
type = int;
default = 0;
};
custom = mkOption {
type = bool;
default = false;
};
algorithm = mkOption {
type = enum ["complementary" "floating" "analogous"];
default = "floating";
};
primary = mkOption {
type = bool;
default = true;
};
lightness = mkOption {
type = int;
default = 0;
};
position.x = mkOption {
type = int;
default = 0;
};
position.y = mkOption {
type = int;
default = 0;
};
type = mkOption {
type = enum ["undefined" "explicit-lightness"];
default = "undefined";
};
};
})));
default = [];
};
theme.opacity = mkOption {
type = nullOr float;
default = 0.5;
};
theme.rotation = mkOption {
type = nullOr int;
default = null;
};
theme.texture = mkOption {
type = nullOr float;
default = 0.0;
};
attrsOf (
submodule (
{...}: {
options = {
spacesForce = mkOption {
type = bool;
description = "Whether to delete existing spaces not declared in the configuration.";
default = false;
};
}));
default = {};
};
};
}));
spaces = mkOption {
type = attrsOf (
submodule (
{name, ...}: {
options = {
name = mkOption {
type = str;
description = "Name of the space.";
default = name;
};
id = mkOption {
type = str;
description = "REQUIRED. Unique Version 4 UUID for space.";
};
position = mkOption {
type = ints.unsigned;
description = "Position of space in the left bar.";
default = 1000;
};
icon = mkOption {
type = nullOr (either str path);
description = "Emoji or icon URI to be used as space icon.";
apply = v:
if isPath v
then "file://${v}"
else v;
default = null;
};
container = mkOption {
type = nullOr ints.unsigned;
description = "Container ID to be used in space";
default = null;
};
theme.type = mkOption {
type = nullOr str;
default = "gradient";
};
theme.colors = mkOption {
type = nullOr (
listOf (
submodule (
{...}: {
options = {
red = mkOption {
type = ints.between 0 255;
default = 0;
};
green = mkOption {
type = ints.between 0 255;
default = 0;
};
blue = mkOption {
type = ints.between 0 255;
default = 0;
};
custom = mkOption {
type = bool;
default = false;
};
algorithm = mkOption {
type = enum [
"complementary"
"floating"
"analogous"
];
default = "floating";
};
primary = mkOption {
type = bool;
default = true;
};
lightness = mkOption {
type = int;
default = 0;
};
position.x = mkOption {
type = int;
default = 0;
};
position.y = mkOption {
type = int;
default = 0;
};
type = mkOption {
type = enum [
"undefined"
"explicit-lightness"
];
default = "undefined";
};
};
}
)
)
);
default = [];
};
theme.opacity = mkOption {
type = nullOr float;
default = 0.5;
};
theme.rotation = mkOption {
type = nullOr int;
default = null;
};
theme.texture = mkOption {
type = nullOr float;
default = 0.0;
};
};
}
)
);
default = {};
};
};
}
)
);
};
};
@@ -187,140 +210,187 @@ in {
};
};
systemd.user.services."zen-browser-spaces-activation" = let
home.file = let
inherit
(builtins)
isNull
toJSON
toString
;
inherit
(lib)
attrByPath
concatMapAttrsStringSep
concatMapStringsSep
concatStringsSep
elemAt
concatMapStringsSep
concatMapAttrsStringSep
filterAttrs
getExe
getExe'
isStringLike
lists
mapAttrs'
mapAttrsToList
nameValuePair
optionalString
pipe
;
in (mapAttrs' (profileName: profile: let
sqlite3 = getExe' pkgs.sqlite "sqlite3";
scriptFile = "${configPath}/${profileName}/places_update.sh";
placesFile = "${config.home.homeDirectory}/${configPath}/${profileName}/places.sqlite";
hasSpaces = pipe cfg.profiles [
(mapAttrsToList (n: v: v.spaces != {}))
(lists.any (v: v))
];
insertSpaces = ''
# Reference: https://github.com/zen-browser/desktop/blob/4e2dfd8a138fd28767bb4799a3ca9d8aab80430e/src/zen/workspaces/ZenWorkspacesStorage.mjs#L25-L55
${sqlite3} "${placesFile}" "${
concatStringsSep " " [
"CREATE TABLE IF NOT EXISTS zen_workspaces ("
"id INTEGER PRIMARY KEY,"
"uuid TEXT UNIQUE NOT NULL,"
"name TEXT NOT NULL,"
"icon TEXT,"
"container_id INTEGER,"
"position INTEGER NOT NULL DEFAULT 0,"
"created_at INTEGER NOT NULL,"
"updated_at INTEGER NOT NULL"
");"
]
}" || exit 1
# Reference: https://github.com/zen-browser/desktop/blob/4e2dfd8a138fd28767bb4799a3ca9d8aab80430e/src/zen/workspaces/ZenWorkspacesStorage.mjs#L25-L55
initSpacesTable = pkgs.writeText "init.sql" ''
CREATE TABLE IF NOT EXISTS zen_workspaces (
id INTEGER PRIMARY KEY,
uuid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
icon TEXT,
container_id INTEGER,
position INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
columns=($(${sqlite3} "${placesFile}" "SELECT name FROM pragma_table_info('zen_workspaces');"))
if [[ ! "''${columns[@]}" =~ "theme_type" ]]; then
${sqlite3} "${placesFile}" "ALTER TABLE zen_workspaces ADD COLUMN theme_type TEXT;" || exit 1
fi
if [[ ! "''${columns[@]}" =~ "theme_colors" ]]; then
${sqlite3} "${placesFile}" "ALTER TABLE zen_workspaces ADD COLUMN theme_colors TEXT;" || exit 1
fi
if [[ ! "''${columns[@]}" =~ "theme_opacity" ]]; then
${sqlite3} "${placesFile}" "ALTER TABLE zen_workspaces ADD COLUMN theme_opacity REAL;" || exit 1
fi
if [[ ! "''${columns[@]}" =~ "theme_rotation" ]]; then
${sqlite3} "${placesFile}" "ALTER TABLE zen_workspaces ADD COLUMN theme_rotation INTEGER;" || exit 1
fi
if [[ ! "''${columns[@]}" =~ "theme_texture" ]]; then
${sqlite3} "${placesFile}" "ALTER TABLE zen_workspaces ADD COLUMN theme_texture REAL;" || exit 1
fi
theme_type TEXT,
theme_colors TEXT,
theme_opacity REALj
theme_rotation INTEGER,
theme_texture REAL
)
'';
# Reference: https://github.com/zen-browser/desktop/blob/4e2dfd8a138fd28767bb4799a3ca9d8aab80430e/src/zen/workspaces/ZenWorkspacesStorage.mjs#L141-L149
${sqlite3} "${placesFile}" "${
(concatStringsSep " " [
"INSERT OR REPLACE INTO zen_workspaces ("
"uuid,"
"name,"
"icon,"
"container_id,"
"position,"
# Source: https://github.com/zen-browser/desktop/blob/4e2dfd8a138fd28767bb4799a3ca9d8aab80430e/src/zen/workspaces/ZenWorkspacesStorage.mjs#L141-L149
updateSpacesTable = spaces:
pkgs.writeText "insert.sql" ''
INSERT OR REPLACE INTO zen_workspaces (
uuid,
name,
icon,
container_id,
"position",
"theme_type,"
"theme_colors,"
"theme_opacity,"
"theme_rotation,"
"theme_texture,"
theme_type,
theme_colors,
theme_opacity,
theme_rotation,
theme_texture,
created_at,
updated_at
) VALUES ${pipe spaces [
(mapAttrsToList (_: space: [
"{${space.id}}"
space.name
(attrByPath ["icon"] null space)
(attrByPath ["container"] null space)
(attrByPath ["position"] 0 space)
(attrByPath ["theme" "type"] "gradient" space)
(map (color: {
inherit (color) algorithm lightness position type;
c = [color.red color.green color.blue];
isCustom = color.custom;
isPrimary = color.primary;
}) (attrByPath ["theme" "colors"] [] space))
(attrByPath ["theme" "opacity"] 0.5 space)
(attrByPath ["theme" "rotation"] null space)
(attrByPath ["theme" "texture"] 0.0 space)
]))
(map (row:
map (
v:
with builtins;
if isStringLike v
then "'${v}'"
else if (isList v) || (isAttrs v)
then "'${toJSON v}'"
else if isNull v
then "NULL"
else toString v
"created_at,"
"updated_at"
") VALUES "
])
+ (pipe profile.spaces [
(mapAttrsToList (_: s: [
"'{${s.id}}'"
"'${s.name}'"
(
if isNull s.icon
then "NULL"
else "'${s.icon}'"
)
row))
(map (row:
row
++ [
"COALESCE((SELECT created_at FROM zen_workspaces WHERE uuid = ${elemAt row 0}), strftime('%s', 'now'))"
"strftime('%s', 'now')"
]))
(
if isNull s.container
then "NULL"
else toString s.container
)
(toString s.position)
(
if isNull s.theme.type
then "NULL"
else "'${s.theme.type}'"
)
(
if isNull s.theme.colors
then "NULL"
else "'${toJSON (map (c: {
inherit (c) algorithm lightness position type;
c = [c.red c.green c.blue];
isCustom = c.custom;
isPrimary = c.primary;
})
s.theme.colors)}'"
)
(
if isNull s.theme.opacity
then "NULL"
else toString s.theme.opacity
)
(
if isNull s.theme.rotation
then "NULL"
else toString s.theme.rotation
)
(
if isNull s.theme.texture
then "NULL"
else toString s.theme.texture
)
"COALESCE((SELECT created_at FROM zen_workspaces WHERE uuid = '{${s.id}}'), strftime('%s', 'now'))"
"strftime('%s', 'now')"
]))
(map (row: concatStringsSep "," row))
(concatMapStringsSep "," (row: "(${row})"))
]}
'';
])
}" || exit 1
'';
filterSpacesTable = spaces:
pkgs.writeText "filter.sql" ''
DELETE FROM zen_workspaces ${
if spaces != {}
then "WHERE "
else ""
}${
concatMapAttrsStringSep " AND " (n: v: "NOT uuid = '{${v.id}}'") spaces
}
'';
deleteSpaces = ''
${sqlite3} "${placesFile}" "DELETE FROM zen_workspaces ${
if profile.spaces != {}
then "WHERE "
else ""
}${concatMapAttrsStringSep " AND " (_: s: "NOT uuid = '{${s.id}}'") profile.spaces}" || exit 1
'';
in
mkIf hasSpaces {
Install.WantedBy = ["default.target"];
Unit.After = ["home-manager-${config.home.username}.service"];
Service = {
Type = "oneshot";
ExecStart = let
sqlite3 = getExe' pkgs.sqlite "sqlite3";
in
pipe cfg.profiles [
(filterAttrs (_: v: v.spaces != {}))
(mapAttrsToList (n: v: (pkgs.writeShellScriptBin "zen-browser-spaces-${n}" ''
mkdir -p "${configPath}/${n}"
${sqlite3} "${configPath}/${n}/places.sqlite" ".read ${initSpacesTable}"
${sqlite3} "${configPath}/${n}/places.sqlite" ".read ${updateSpacesTable v.spaces}"
${optionalString v.spacesForce
''${sqlite3} "${configPath}/${n}/places.sqlite" ".read ${filterSpacesTable v.spaces}"''}
'')))
(list: map (pkg: getExe pkg) list)
];
};
};
nameValuePair scriptFile {
source = getExe (pkgs.writeShellScriptBin "places_update_${profileName}" ''
# This file is generated by Zen browser Home Manager module, please to not change it since it
# will be overridden and executed on every rebuild of the home environment.
function update_spaces() {
${optionalString (profile.spaces != {}) insertSpaces}
${optionalString (profile.spacesForce) deleteSpaces}
}
error="$(update_spaces 2>&1 1>/dev/null)"
if [[ "$?" -ne 0 ]]; then
if [[ "$error" == *"database is locked"* ]]; then
echo "$error"
YELLOW="\033[1;33m"
NC="\033[0m"
echo -e "zen-update-places:''${YELLOW} Atempted to update the \"zen_workspaces\" table with values declared in \"programs.zen.profiles.\"${profileName}\".spaces\".''${NC}"
echo -e "zen-update-places:''${YELLOW} Failed to update \"${placesFile}\" due to a Zen browser instance for profile \"${profileName}\" being opened, please close''${NC}"
echo -e "zen-update-places:''${YELLOW} Zen browser and rebuild the home environment to rerun \"home-manager-${config.home.username}.service\" and update places.sqlite.''${NC}"
else
echo "$error"
fi
exit 1
else
exit 0
fi
'');
onChange = ''
${config.home.homeDirectory}/${scriptFile}
if [[ "$?" -ne 0 ]]; then
RED="\033[0;31m"
NC="\033[0m"
echo -e "zen-update-places:''${RED} Failed to update places.sqlite file for Zen browser \"${profileName}\" profile.''${NC}"
fi
'';
executable = true;
force = true;
}) (filterAttrs (_: profile: profile.spaces != {} || profile.spacesForce) cfg.profiles));
};
}