mirror of
https://github.com/nix-community/home-manager.git
synced 2025-11-08 19:46:05 +01:00
Merge 415153929c into 0562fef070
This commit is contained in:
commit
2f3efbdef6
10 changed files with 599 additions and 0 deletions
11
modules/misc/news/2025/10/2025-10-01_06-44-15.nix
Normal file
11
modules/misc/news/2025/10/2025-10-01_06-44-15.nix
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
time = "2025-10-01T12:44:15+00:00";
|
||||
condition = true;
|
||||
message = ''
|
||||
A new module is available: 'programs.webApps'.
|
||||
|
||||
This module enables declarative configuration of web applications as
|
||||
desktop entries, supporting Chromium-based browsers, Firefox, and
|
||||
automatic browser detection with proper app mode integration.
|
||||
'';
|
||||
}
|
||||
212
modules/programs/webapps.nix
Normal file
212
modules/programs/webapps.nix
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
with lib;
|
||||
|
||||
let
|
||||
cfg = config.programs.webApps;
|
||||
|
||||
# Type for a single web app
|
||||
webAppOpts = types.submodule ({
|
||||
options = {
|
||||
url = mkOption {
|
||||
type = types.str;
|
||||
description = "URL of the web application to launch.";
|
||||
example = "https://github.com";
|
||||
};
|
||||
|
||||
name = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "Name of the web application. If not provided, will be derived from the attribute name.";
|
||||
example = "GitHub";
|
||||
};
|
||||
|
||||
icon = mkOption {
|
||||
type = types.nullOr (types.either types.str types.path);
|
||||
default = null;
|
||||
description = ''
|
||||
Icon for the web application.
|
||||
Can be a path to an icon file or a name of an icon from the current theme.
|
||||
|
||||
For best results, use declarative icon packages like:
|
||||
- `"$${pkgs.papirus-icon-theme}/share/icons/Papirus/64x64/apps/Gmail-mail.google.com.svg"`
|
||||
- Theme icon names like `"mail-client"` (requires icon theme in `home.packages`)
|
||||
|
||||
Popular icon themes: papirus-icon-theme, adwaita-icon-theme, arc-icon-theme
|
||||
'';
|
||||
example = literalExpression ''
|
||||
"$${pkgs.papirus-icon-theme}/share/icons/Papirus/64x64/apps/Gmail-mail.google.com.svg"
|
||||
'';
|
||||
};
|
||||
|
||||
categories = mkOption {
|
||||
type = with types; nullOr (listOf str);
|
||||
default = [
|
||||
"Network"
|
||||
"WebBrowser"
|
||||
];
|
||||
description = "Categories in which the entry should be shown in application menus.";
|
||||
example = ''[ "Development" "Network" ]'';
|
||||
};
|
||||
|
||||
mimeTypes = mkOption {
|
||||
type = with types; nullOr (listOf str);
|
||||
default = null;
|
||||
description = "The MIME types supported by this application.";
|
||||
example = ''[ "x-scheme-handler/mailto" ]'';
|
||||
};
|
||||
|
||||
startupWmClass = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "The StartupWMClass to use in the .desktop file.";
|
||||
example = "github.com";
|
||||
};
|
||||
|
||||
extraOptions = mkOption {
|
||||
type = types.attrs;
|
||||
default = { };
|
||||
description = "Extra options to pass to the browser when launching the webapp.";
|
||||
example = ''{ profile-directory = "Profile 3"; }'';
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
# Get browser command based on package
|
||||
getBrowserCommand =
|
||||
browserPkg: url: extraOptions:
|
||||
let
|
||||
# Desktop entries don't need shell escaping, just basic space escaping
|
||||
escapeDesktopArg = arg: builtins.replaceStrings [ " " ] [ "\\ " ] (toString arg);
|
||||
|
||||
optionString = concatStringsSep " " (
|
||||
mapAttrsToList (name: value: "--${name}=${escapeDesktopArg value}") extraOptions
|
||||
);
|
||||
|
||||
# Detect browser type from package name
|
||||
browserName = browserPkg.pname or (builtins.parseDrvName browserPkg.name).name;
|
||||
|
||||
isChromiumBased = elem browserName [
|
||||
"chromium"
|
||||
"brave"
|
||||
"google-chrome"
|
||||
"google-chrome-stable"
|
||||
"vivaldi"
|
||||
];
|
||||
|
||||
binary = "${toString browserPkg}/bin/${browserName}";
|
||||
in
|
||||
if isChromiumBased then
|
||||
"${binary} --app=${escapeDesktopArg url} ${optionString}"
|
||||
else if browserName == "firefox" then
|
||||
"${binary} ${escapeDesktopArg url}" # Firefox doesn't support --app mode
|
||||
else
|
||||
# Fallback: assume chromium-based behavior
|
||||
"${binary} --app=${escapeDesktopArg url} ${optionString}";
|
||||
|
||||
# Auto-detect browser if not explicitly set
|
||||
detectedBrowser =
|
||||
if cfg.browser != null then
|
||||
cfg.browser
|
||||
else if config.programs.chromium.enable && config.programs.chromium.package != null then
|
||||
config.programs.chromium.package
|
||||
else if config.programs.brave.enable && config.programs.brave.package != null then
|
||||
config.programs.brave.package
|
||||
else if config.programs.firefox.enable && config.programs.firefox.package != null then
|
||||
config.programs.firefox.package
|
||||
else
|
||||
pkgs.chromium; # Default fallback
|
||||
|
||||
# Create a desktop entry for a webapp
|
||||
makeWebAppDesktopEntry =
|
||||
name: appCfg:
|
||||
let
|
||||
# Derive app name if not explicitly set
|
||||
appName = if appCfg.name != null then appCfg.name else name;
|
||||
|
||||
# Get the browser package
|
||||
browserPkg = detectedBrowser;
|
||||
|
||||
# Create the launch command
|
||||
launchCommand = getBrowserCommand browserPkg appCfg.url appCfg.extraOptions;
|
||||
|
||||
# Get browser name for StartupWMClass
|
||||
browserName = browserPkg.pname or (builtins.parseDrvName browserPkg.name).name;
|
||||
|
||||
# Prepare StartupWMClass
|
||||
startupWmClass =
|
||||
if appCfg.startupWmClass != null then appCfg.startupWmClass else "${browserName}-webapp-${name}";
|
||||
in
|
||||
nameValuePair "webapp-${name}" {
|
||||
name = appName;
|
||||
genericName = "${appName} Web App";
|
||||
exec = launchCommand;
|
||||
icon = appCfg.icon;
|
||||
terminal = false;
|
||||
type = "Application";
|
||||
categories = appCfg.categories;
|
||||
mimeType = appCfg.mimeTypes;
|
||||
settings = {
|
||||
StartupWMClass = startupWmClass;
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
meta.maintainers = with lib.maintainers; [ realsnick ];
|
||||
|
||||
options.programs.webApps = {
|
||||
enable = mkEnableOption "web applications";
|
||||
|
||||
browser = mkOption {
|
||||
type = types.nullOr types.package;
|
||||
default = null;
|
||||
example = literalExpression "pkgs.chromium";
|
||||
description = ''
|
||||
Browser package to use for launching web applications.
|
||||
If null, will try to auto-detect from enabled browser programs.
|
||||
Chromium-based browsers (chromium, brave, google-chrome) work best with --app mode.
|
||||
'';
|
||||
};
|
||||
|
||||
apps = mkOption {
|
||||
type = types.attrsOf webAppOpts;
|
||||
default = { };
|
||||
description = "Set of web applications to install.";
|
||||
example = literalExpression ''
|
||||
{
|
||||
github = {
|
||||
url = "https://github.com";
|
||||
icon = "github";
|
||||
categories = [ "Development" "Network" ];
|
||||
};
|
||||
gmail = {
|
||||
url = "https://mail.google.com";
|
||||
name = "Gmail";
|
||||
icon = ./icons/gmail.png;
|
||||
mimeTypes = [ "x-scheme-handler/mailto" ];
|
||||
};
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.browser == null || lib.isDerivation cfg.browser;
|
||||
message = ''
|
||||
programs.webApps: browser must be a package derivation or null for auto-detection.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# Create desktop entries for each web app
|
||||
xdg.desktopEntries = mapAttrs' makeWebAppDesktopEntry cfg.apps;
|
||||
};
|
||||
}
|
||||
27
tests/flake.lock
generated
Normal file
27
tests/flake.lock
generated
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1759036355,
|
||||
"narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
36
tests/modules/programs/webapps/auto-detect.nix
Normal file
36
tests/modules/programs/webapps/auto-detect.nix
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{ pkgs, ... }:
|
||||
|
||||
{
|
||||
config = {
|
||||
# Enable brave browser program to test auto-detection
|
||||
programs.brave = {
|
||||
enable = true;
|
||||
package = pkgs.brave;
|
||||
};
|
||||
|
||||
programs.webApps = {
|
||||
enable = true;
|
||||
# browser = null; (let it auto-detect from brave)
|
||||
|
||||
apps = {
|
||||
discord = {
|
||||
url = "https://discord.com/channels/@me";
|
||||
name = "Discord";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
nmt.script = ''
|
||||
# Check that the desktop entry was created
|
||||
assertFileExists home-path/share/applications/webapp-discord.desktop
|
||||
|
||||
# Check that it detected brave and used --app mode
|
||||
assertFileRegex home-path/share/applications/webapp-discord.desktop \
|
||||
'Exec=.*brave.*--app=https://discord.com/channels/@me'
|
||||
|
||||
# Check StartupWMClass uses brave
|
||||
assertFileRegex home-path/share/applications/webapp-discord.desktop \
|
||||
'StartupWMClass=brave-webapp-discord'
|
||||
'';
|
||||
};
|
||||
}
|
||||
43
tests/modules/programs/webapps/basic.nix
Normal file
43
tests/modules/programs/webapps/basic.nix
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{ pkgs, ... }:
|
||||
|
||||
{
|
||||
config = {
|
||||
programs.webApps = {
|
||||
enable = true;
|
||||
browser = pkgs.chromium;
|
||||
|
||||
apps = {
|
||||
github = {
|
||||
url = "https://github.com";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
nmt.script = ''
|
||||
# Check that the desktop entry was created
|
||||
assertFileExists home-path/share/applications/webapp-github.desktop
|
||||
|
||||
# Check basic desktop entry content
|
||||
assertFileRegex home-path/share/applications/webapp-github.desktop \
|
||||
'Name=github'
|
||||
assertFileRegex home-path/share/applications/webapp-github.desktop \
|
||||
'GenericName=github Web App'
|
||||
assertFileRegex home-path/share/applications/webapp-github.desktop \
|
||||
'Type=Application'
|
||||
assertFileRegex home-path/share/applications/webapp-github.desktop \
|
||||
'Terminal=false'
|
||||
|
||||
# Check the exec command contains chromium with --app
|
||||
assertFileRegex home-path/share/applications/webapp-github.desktop \
|
||||
'Exec=.*chromium.*--app=https://github.com'
|
||||
|
||||
# Check categories
|
||||
assertFileRegex home-path/share/applications/webapp-github.desktop \
|
||||
'Categories=Network;WebBrowser'
|
||||
|
||||
# Check StartupWMClass
|
||||
assertFileRegex home-path/share/applications/webapp-github.desktop \
|
||||
'StartupWMClass=chromium-webapp-github'
|
||||
'';
|
||||
};
|
||||
}
|
||||
67
tests/modules/programs/webapps/custom-options.nix
Normal file
67
tests/modules/programs/webapps/custom-options.nix
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
{ pkgs, ... }:
|
||||
|
||||
{
|
||||
config = {
|
||||
programs.webApps = {
|
||||
enable = true;
|
||||
browser = pkgs.chromium;
|
||||
|
||||
apps = {
|
||||
gmail = {
|
||||
url = "https://mail.google.com";
|
||||
name = "Gmail";
|
||||
categories = [
|
||||
"Office"
|
||||
"Network"
|
||||
"Email"
|
||||
];
|
||||
mimeTypes = [ "x-scheme-handler/mailto" ];
|
||||
startupWmClass = "gmail-webapp";
|
||||
extraOptions = {
|
||||
"profile-directory" = "Profile 2";
|
||||
"user-data-dir" = "/tmp/gmail-profile";
|
||||
};
|
||||
};
|
||||
|
||||
simple = {
|
||||
url = "https://example.com";
|
||||
# Test minimal configuration
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
nmt.script = ''
|
||||
# Test Gmail with custom options
|
||||
assertFileExists home-path/share/applications/webapp-gmail.desktop
|
||||
|
||||
# Check custom name and categories
|
||||
assertFileRegex home-path/share/applications/webapp-gmail.desktop \
|
||||
'Name=Gmail'
|
||||
assertFileRegex home-path/share/applications/webapp-gmail.desktop \
|
||||
'Categories=Office;Network;Email'
|
||||
|
||||
# Check MIME type support
|
||||
assertFileRegex home-path/share/applications/webapp-gmail.desktop \
|
||||
'MimeType=x-scheme-handler/mailto'
|
||||
|
||||
# Check custom StartupWMClass
|
||||
assertFileRegex home-path/share/applications/webapp-gmail.desktop \
|
||||
'StartupWMClass=gmail-webapp'
|
||||
|
||||
# Check extra browser options are included
|
||||
assertFileRegex home-path/share/applications/webapp-gmail.desktop \
|
||||
'Exec=.*--profile-directory=Profile\\ 2.*'
|
||||
assertFileRegex home-path/share/applications/webapp-gmail.desktop \
|
||||
'Exec=.*--user-data-dir=/tmp/gmail-profile.*'
|
||||
|
||||
# Test simple app with defaults
|
||||
assertFileExists home-path/share/applications/webapp-simple.desktop
|
||||
assertFileRegex home-path/share/applications/webapp-simple.desktop \
|
||||
'Name=simple'
|
||||
assertFileRegex home-path/share/applications/webapp-simple.desktop \
|
||||
'Categories=Network;WebBrowser'
|
||||
assertFileRegex home-path/share/applications/webapp-simple.desktop \
|
||||
'StartupWMClass=chromium-webapp-simple'
|
||||
'';
|
||||
};
|
||||
}
|
||||
10
tests/modules/programs/webapps/default.nix
Normal file
10
tests/modules/programs/webapps/default.nix
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{ lib, pkgs, ... }:
|
||||
|
||||
lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
|
||||
webapps-basic = ./basic.nix;
|
||||
webapps-explicit-browser = ./explicit-browser.nix;
|
||||
webapps-auto-detect = ./auto-detect.nix;
|
||||
webapps-custom-options = ./custom-options.nix;
|
||||
webapps-gmail-example = ./gmail-example.nix;
|
||||
webapps-package-icons = ./package-icons.nix;
|
||||
}
|
||||
47
tests/modules/programs/webapps/explicit-browser.nix
Normal file
47
tests/modules/programs/webapps/explicit-browser.nix
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{ pkgs, ... }:
|
||||
|
||||
{
|
||||
config = {
|
||||
programs.webApps = {
|
||||
enable = true;
|
||||
browser = pkgs.firefox;
|
||||
|
||||
apps = {
|
||||
youtube = {
|
||||
url = "https://youtube.com";
|
||||
name = "YouTube";
|
||||
icon = "applications-multimedia";
|
||||
categories = [
|
||||
"AudioVideo"
|
||||
"Network"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
nmt.script = ''
|
||||
# Check that the desktop entry was created
|
||||
assertFileExists home-path/share/applications/webapp-youtube.desktop
|
||||
|
||||
# Check custom name
|
||||
assertFileRegex home-path/share/applications/webapp-youtube.desktop \
|
||||
'Name=YouTube'
|
||||
|
||||
# Check Firefox exec (no --app mode for Firefox)
|
||||
assertFileRegex home-path/share/applications/webapp-youtube.desktop \
|
||||
'Exec=.*firefox.*https://youtube.com'
|
||||
|
||||
# Make sure it doesn't contain --app (Firefox doesn't support it)
|
||||
assertFileNotRegex home-path/share/applications/webapp-youtube.desktop \
|
||||
'Exec=.*--app.*'
|
||||
|
||||
# Check custom categories
|
||||
assertFileRegex home-path/share/applications/webapp-youtube.desktop \
|
||||
'Categories=AudioVideo;Network'
|
||||
|
||||
# Check icon
|
||||
assertFileRegex home-path/share/applications/webapp-youtube.desktop \
|
||||
'Icon=applications-multimedia'
|
||||
'';
|
||||
};
|
||||
}
|
||||
68
tests/modules/programs/webapps/gmail-example.nix
Normal file
68
tests/modules/programs/webapps/gmail-example.nix
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
{ pkgs, ... }:
|
||||
|
||||
{
|
||||
programs.webApps = {
|
||||
enable = true;
|
||||
browser = pkgs.chromium;
|
||||
|
||||
apps = {
|
||||
# Method 1: Using theme icon names (most compatible)
|
||||
gmail = {
|
||||
url = "https://mail.google.com";
|
||||
name = "Gmail";
|
||||
icon = "mail-client";
|
||||
categories = [
|
||||
"Network"
|
||||
"Email"
|
||||
"Office"
|
||||
];
|
||||
mimeTypes = [ "x-scheme-handler/mailto" ];
|
||||
startupWmClass = "gmail-webapp";
|
||||
};
|
||||
|
||||
# Method 2: Using general theme icon names
|
||||
calendar = {
|
||||
url = "https://calendar.google.com";
|
||||
name = "Google Calendar";
|
||||
icon = "calendar";
|
||||
categories = [
|
||||
"Office"
|
||||
"Calendar"
|
||||
];
|
||||
};
|
||||
|
||||
# Method 3: Using web browser icon as fallback
|
||||
slack = {
|
||||
url = "https://slack.com";
|
||||
name = "Slack";
|
||||
icon = "web-browser";
|
||||
categories = [
|
||||
"Network"
|
||||
"Chat"
|
||||
];
|
||||
};
|
||||
|
||||
# Method 4: Using application icon
|
||||
discord = {
|
||||
url = "https://discord.com/app";
|
||||
name = "Discord";
|
||||
icon = "application-x-executable";
|
||||
categories = [
|
||||
"Network"
|
||||
"Chat"
|
||||
];
|
||||
};
|
||||
|
||||
# Method 5: Using folder icon (always available)
|
||||
github = {
|
||||
url = "https://github.com";
|
||||
name = "GitHub";
|
||||
icon = "folder";
|
||||
categories = [
|
||||
"Development"
|
||||
"Network"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
78
tests/modules/programs/webapps/package-icons.nix
Normal file
78
tests/modules/programs/webapps/package-icons.nix
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
{ config, pkgs, ... }:
|
||||
|
||||
{
|
||||
xdg.enable = true;
|
||||
|
||||
programs.webApps = {
|
||||
enable = true;
|
||||
browser = pkgs.chromium;
|
||||
|
||||
apps = {
|
||||
# Test with a package path using stubbed package
|
||||
package-icon-test = {
|
||||
url = "https://example.com";
|
||||
name = "Package Icon Test";
|
||||
# Use mock icon theme package created by test.stubs
|
||||
icon = "${pkgs.mockicontheme}/share/icons/test.svg";
|
||||
categories = [ "Network" ];
|
||||
};
|
||||
|
||||
# Test with string icon name for comparison
|
||||
string-icon-test = {
|
||||
url = "https://example2.com";
|
||||
name = "String Icon Test";
|
||||
icon = "folder";
|
||||
categories = [ "Network" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Create a mock icon theme package for testing
|
||||
test.stubs = {
|
||||
mockicontheme = {
|
||||
outPath = null;
|
||||
buildScript = ''
|
||||
mkdir -p $out/share/icons
|
||||
echo "mock test icon" > $out/share/icons/test.svg
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# Test that the desktop entries are created correctly
|
||||
nmt.script = ''
|
||||
# Check that desktop entries exist in the correct location
|
||||
assertFileExists "$TESTED/home-path/share/applications/webapp-package-icon-test.desktop"
|
||||
assertFileExists "$TESTED/home-path/share/applications/webapp-string-icon-test.desktop"
|
||||
|
||||
# Check that package-icon-test has the package-based icon path
|
||||
# Note: test.stubs creates packages named "dummy" in the Nix store
|
||||
assertFileContains "$TESTED/home-path/share/applications/webapp-package-icon-test.desktop" \
|
||||
'Icon=/nix/store/'
|
||||
assertFileContains "$TESTED/home-path/share/applications/webapp-package-icon-test.desktop" \
|
||||
'dummy'
|
||||
assertFileContains "$TESTED/home-path/share/applications/webapp-package-icon-test.desktop" \
|
||||
'test.svg'
|
||||
|
||||
# Check that string-icon-test has the string icon name
|
||||
assertFileContains "$TESTED/home-path/share/applications/webapp-string-icon-test.desktop" \
|
||||
'Icon=folder'
|
||||
|
||||
# Verify the URLs are correct
|
||||
assertFileContains "$TESTED/home-path/share/applications/webapp-package-icon-test.desktop" \
|
||||
'https://example.com'
|
||||
assertFileContains "$TESTED/home-path/share/applications/webapp-string-icon-test.desktop" \
|
||||
'https://example2.com'
|
||||
|
||||
# Check the app mode launch commands are correct
|
||||
assertFileContains "$TESTED/home-path/share/applications/webapp-package-icon-test.desktop" \
|
||||
'Exec=@chromium@/bin/chromium --app=https://example.com'
|
||||
assertFileContains "$TESTED/home-path/share/applications/webapp-string-icon-test.desktop" \
|
||||
'Exec=@chromium@/bin/chromium --app=https://example2.com'
|
||||
|
||||
# Check the proper generic names are set
|
||||
assertFileContains "$TESTED/home-path/share/applications/webapp-package-icon-test.desktop" \
|
||||
'GenericName=Package Icon Test Web App'
|
||||
assertFileContains "$TESTED/home-path/share/applications/webapp-string-icon-test.desktop" \
|
||||
'GenericName=String Icon Test Web App'
|
||||
'';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue