feat: v0.1.0 #1

Merged
dmitry merged 12 commits from dev into main 2025-12-27 12:05:30 +03:00
5 changed files with 589 additions and 2 deletions
Showing only changes of commit e5771e096a - Show all commits

4
Makefile Normal file
View File

@@ -0,0 +1,4 @@
.PNONY: install
install:
sudo install userctx.py /usr/local/bin/userctx

View File

@@ -1,3 +1,45 @@
# userctx # themer
A tool for switching user's software configs Apply visual themes to various apps with ease.
## Kitty
Themer symlinks ~/.config/kitty/current-theme.conf to the
file found within theme directory.
So:
1. touch ~/.config/kitty/current-theme.conf
2. echo 'include current-theme.conf' >> ~/.config/kitty/kitty.conf
If you even called kitten theme 'Some Theme' then
your setup is fine and ready for Themer.
## Niri
If ~/.config/niri/configs/ dir exists then
config is assembled from this dir + theme.
Else niri.kdl from theme directory is considered the whole config and
Themer symlinks ~/.config/niri/config.kdl to you niri.kdl inside
theme directory.
## Sway
Replaces either ~/.config/sway/theme.conf or whole config.
## Swaybg
If using swaybg in other compositor besides sway
then it must be a systemd unit for Themer to manage it.
Put the following to ~/.config/systemd/user/swaybg.service
```
[Unit]
PartOf=graphical-session.target
After=graphical-session.target
Requisite=graphical-session.target
[Service]
ExecStart=/usr/bin/swaybg -m fill -i "%h/.config/swaybg/wallpaper"
Restart=on-failure
```
1. mkdir ~/.config/swaybg
2. ln -s /path/to/any/wallpaper ~/.config/swaybg/wallpaper
3. systemctl --user daemon-reload
4. systemctl --user add-wants niri.service swaybg.service
https://github.com/YaLTeR/niri/wiki/Example-systemd-Setup

58
TODO.md Normal file
View File

@@ -0,0 +1,58 @@
How this must work.
1. On first launch themere reads ~/.config/themer/config and aquires list of apps it should manage
2. Themer backs up whole config directories to ~/.config/themer so further manipulations are safe
Default name is themer/original, but user can provide a different name from command line.
example:
themer backup [--name <name>] [--apps TODO]
default name is "original"
default apps list is read from config. command line overrides config completely, no merging.
Backup algorithm:
- If directory ~/.config/sway does not exist or is empty will do nothing
- if directory exists AND not empty then create directory ~/.config/themer/original/sway
- copy contents of all files AND direcotries in it to ~/.config/themer/original/sway
- if any file failed to copy - rollback and remove ~/.config/themer/original/sway
- remove original files (and directories) from ~/.config/sway
- create symlinks to copied files in ~/.config/sway
3. Swithing algorithm:
User requests theme change with:
themer apply <theme>
Themer tries to apply configs as follows:
- looks up a list of apps it should manage; if list is empty - return non-zero
- looks up requested theme name in ~/.config/themer/<name>; if not found - return non-zero
- looks up config dir ~/.config/themer/<theme>/<app>; if none found - return non-zero
- for each <filename> in ~/.config/themer/<theme>/<app> themer does
```
mkdir -p ~/.config/<app> # creates app config directory if it does not exist (else no-op)
rm -f ~/.config/<app>/<filename> # tries to delete the config if it exists (else no-op)
ln -s ~/.config/themer/<theme>/<app>/<filename> ~/.config/<app>/<filename> # symlinks the file from requested theme user dir
```
- after creating symlinks themer tries to let the app know that config changed
- if app has hot-reload (niri, alacritty, hyprland) - do nothing
- if app re-reads configs on start (fuzzel, wofi) - do nothing
- if app obeys USR1/URS2 - issue the signal
- if app is a systemd service - exec systemctl --user restart <app>
This way a single copy of config files are kept in ~/.config/themer.
This way user can choose which files to manage themselves and which themer
should manage.
4. cli interface
themer list - returns newline-separated list (suitable for dmenu)
themer apply $(themer list | fuzzel -d) - a usable way to select theme with dmenu-like apps
TODO:
0. Update README.md, make app usable for a random person on the internet.
1. implement backup
2. parse cli args in a decent way
3. implement behaviour discussed above
4. Think of config options: configure ability to merge several files; map contents of theme dir to specific files
Wider TODO:
1. Think of this app as a user context switcher, not only theme manager.

190
config.toml Normal file
View File

@@ -0,0 +1,190 @@
[general]
# if dry run is set to true
# userctx will only validate config
# and tell you what it will do to execute
# tasks
dry_run = true
# "tasks" array must not be empty
# it tells userctx what to do (which apps
# should be actually managed)
tasks = [
"sway",
"gtk",
"helix",
"foot",
"wofi",
"mako",
]
# target directory where managed configs
# are located
# defaults to $XDG_CONFIG_HOME if specifically set
# or ~/.config if $XGD_CONFIG_HOME is not set
managed_directory = "~/.config"
# directory where contexts are stored
# default is "~/.config/userctx"
contexts_path = "~/.config/userctx"
# This section of config is intended to document
# what userctx can do. Assume we are
# building a config for app "example"
[task.example]
# the default path to apply your context to is
# ~/.config/example (for app name "example")
# "target_path" overrides this.
# If set then $CONTEXT_DST env will hold this value and
# symlinks will be created relative to this specified
# path.
target_path = "~/.config/example/configs"
# "actions" array defines which steps should be
# executed to apply content from
# context directory to users home
# directory. If actions array is empty the default
# behaviour is to just symlink everything from
# $CONTEXT_SRC to $CONTEXT_DST
actions = [
"symlinks", # do the symlinking as specified in "symlinks array below"
"code", # execute shell script from object "code" below
"commands", # run commands from "commands array below"
"scripts", # run scripts from "scripts" array below
]
# "symlinks" array of mappings (inline tables of TOML) defines
# which files will be symlinked to users home
# directory. If "symlinks" key is present in
# "actions" above but this "symlinks" array is omitted
# then userctx treats all files in the context directory as
# files that must be symlinked to users home.
symlinks = [
# names of files in these mapping are relative to
# context directory and target directory
# by default source is ~/.config/userctx/<context_name>/example for
# task (app) named "example"
# default target directory is ~/.config/example for
# task (app) named "example"
#
# this will link file from context dir to destination dir
# $CONTEXT_SRC/context_file.conf -> $CONTEXT_DST/symlink_to_context_file.conf
{"context_file.conf" = "symlink_to_context_file.conf"},
# this will link file to subdirectory inside destination dir
# directory must be created beforehand
# $CONTEXT_SRC/other_file.conf -> $CONTEXT_DST/subdir/other_file.conf
{"other_file.conf" = "subdir/other_file.conf" },
# also possible option
{"subdir/yet_another_file.conf" = "yet_another_file.conf" },
]
# "code" object let's you write
# a custom script that will apply contents of
# your context. Note that string is a multiline string
# strating and ending with three double-qoutes.
# This string will be passed as is to $SHELL -c <string>
code = """
# this is a normal shell script which will
# be executed by user's $SHELL or 'bash' binary
# if users $SHELL is empty
# users envs are available
echo "$HOME"
# additional envs are available
echo "$CONTEXT_SRC"
echo "$CONTEXT_DST"
echo "$CONTEXT_NAME"
"""
# Another way in run something when applying context
# is to define "commands" array.
commands = [
['first_command', '--arg1', 'value', '--arg2', 'value2'],
['second_command', '--arg1', 'value', '--arg2', 'value2'],
]
# scripts array will try to find
# named files in $CONTEXT_SRC and
# execute them with $SHELL <script>
# IMPORTANT: if array is empty
# and action "scripts" is present in
# "actions" array above then all files
# in $CONTEXT_SRC will be treated as scripts
# (note the "task.gtk section below")
scripts = [
"script1.sh",
"script2.sh",
]
# reload command (if set) will be executed after
# all actions
reload = ['echo', 'this is my reload command']
#########################
settings for some
commonly used apps
#########################
[task.niri]
target_path = "~/.config/niri/configs"
actions = [
"symlinks",
"code",
]
# The snippet below is needed for niri 25.8 which did not
# support imports/includes in the config. I'm splitting
# config into several parts and them concatenating them
# after applying changes to theme. The other parts are left intact.
# Also note the custom target path above. It instructs userctx to
# symlink configs found in the context dir to
# ~/.config/niri/configs, which then get concatenated.
# For niri since 25.11 you can just put your includes
# into the context dir.
code = """
cat ~/.config/niri/configs/* > ~/.config/niri/config.kdl
"""
[task.helix]
actions = [
"symlinks",
"code",
]
# context must contain file named "helix.toml" which describes a theme.
symlinks = [
# $CONTEXT_SRC/helix.toml -> $CONTEXT_DST/themes/current_theme.toml
{"helix.toml" = "themes/current_theme.toml"}
]
code = """
sed -i -E 's/^theme = (.+)/theme = "current_theme"/' ~/.config/helix/config.toml
"""
reload = ['pkill', '-USR1', 'hx']
[task.gtk]
# To setup the look of GTK you may put a simple shell script
# into ~/.userctx/<context_name>/gtk/theme.sh with contents similar to:
# gsettings set org.gnome.desktop.interface color-scheme 'default'
# gsettings set org.gnome.desktop.interface gtk-theme 'Yaru'
# gsettings set org.gnome.desktop.interface icon-theme 'Yaru'
actions = ["scripts"]
[task.mako]
reload = ["makoctl", "reload"]
[task.sway]
reload = ["swaymsg", "reload"]
[task.kitty]
reload = ['pkill', '-USR1', 'kitty']
[task.waybar]
reload = ['pkill', '-USR2', 'waybar']
[task.swaybg]
# This config is for swaybg setup as s systemd service.
# Unit runs "swaybg -m fill -i "%h/.config/swaybg/wallpaper".
# ~/.config/userctx/<context>/swaybg/ contains single file
# called "wallpaper" which gets symlinked to ~/.config/swaybg/wallpaper
target_path = '~/.config/swaybg'
reload = ['systemctl', '--user', 'restart', 'swaybg.service']

293
userctx.py Executable file
View File

@@ -0,0 +1,293 @@
#!/usr/bin/env python3
import os, sys, subprocess, tomllib
COMMAND_LIST = 'list'
COMMAND_LOAD = 'load'
COMMAND_SAVE = 'save'
COMMANDS_ALL = [COMMAND_LIST, COMMAND_LOAD, COMMAND_SAVE]
SECTION_GENERAL = 'general'
KEY_CONTEXTS_PATH = 'contexts_path'
KEY_TASKS = 'tasks'
KEY_DRY_RUN = 'dry_run'
KEY_MANAGED_DIR = 'managed_directory'
SECTION_TASK = 'task'
KEY_SRC_PATH = 'source_path'
KEY_DST_PATH = 'target_path'
KEY_RELOAD = 'reload'
KEY_ACTIONS = 'actions'
KEY_SYMLINK = 'symlinks'
KEY_COMMAND = 'commands'
KEY_SCRIPT = 'scripts'
KEY_CODE = 'code'
ACTIONS_ALL = [KEY_SYMLINK, KEY_COMMAND, KEY_SCRIPT, KEY_CODE]
ENV_CONTEXT_NAME = 'CONTEXT_NAME'
ENV_CONTEXT_SRC = 'CONTEXT_SRC'
ENV_CONTEXT_DST = 'CONTEXT_DST'
HELP_MESSAGE = f'''Usage: {os.path.basename(sys.argv[0])} <command> [context_name]
Commands are:
{COMMAND_LIST} - to list installed contexts (from $HOME/.config/userctx)
{COMMAND_LOAD} - to load context "context_name"
{COMMAND_SAVE} - to save current configs of managed apps as context
"context_name" - must be a directory name present in $HOME/.config/userctx'''
HOME = os.path.expanduser('~')
XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
class OS(object):
def __init__(self, chdir='', env=None):
self.shell = os.getenv('SHELL') or 'bash'
if env: env.update(os.environ)
self.env = env
def exec_command(self, command: list[str], quiet=False) -> int:
return subprocess.run(command, capture_output=quiet, env=self.env).returncode
def exec_script(self, path: str) -> None:
return self.exec_command([self.shell, path])
def exec_snippet(self, code: str) -> None:
return self.exec_command([self.shell, "-c", code])
class Task(object):
def __init__(self, name: str, src: str, dst: str, conf: dict, dry_run=True):
self.name = name
self.src = conf.get(KEY_SRC_PATH) or src # default is ~/.config/userctx/<context>/<task>
self.dst = conf.get(KEY_DST_PATH) or dst # default is ~/.config/<task>
self.actions = conf.get(KEY_ACTIONS) or [KEY_SYMLINK] # default is to just symlink all
self.symlinks = conf.get(KEY_SYMLINK)
self.commands = conf.get(KEY_COMMAND)
self.scripts = conf.get(KEY_SCRIPT)
self.code = conf.get(KEY_CODE)
self.reload_command = conf.get(KEY_RELOAD)
self.shell = OS(env={ENV_CONTEXT_NAME: self.name, ENV_CONTEXT_SRC: self.src, ENV_CONTEXT_DST: self.dst})
self.dry_run = dry_run
def execute(self) -> None:
for a in self.actions:
if a == KEY_SYMLINK:
self._symlink()
elif a == KEY_COMMAND:
self._run_commands()
elif a == KEY_SCRIPT:
self._run_scripts()
elif a == KEY_CODE:
self._run_code()
if self.reload_command and not self.dry_run:
self.shell.exec_command(self.reload_command)
def _symlink(self) -> None:
if not self.symlinks: # will symlink everything we have then
self.symlinks = [{entry.name: entry.name} for entry in os.scandir(self.src)]
for l in self.symlinks:
for k, v in l.items():
src, dst = os.path.join(self.src, k), os.path.join(self.dst, v)
if self.dry_run:
print("will remove:", dst, "will symlink:", src, "to", dst)
continue
if os.path.lexists(dst): os.remove(dst)
os.symlink(src, dst)
def _run_scripts(self) -> None:
if not self.scripts: # nothing provided explicitly
self.scripts = [entry.name for entry in os.scandir(self.src) if entry.is_file()]
if self.dry_run:
print(f'will execute scripts from {self.src}:', self.scripts)
return
for s in self.scripts:
self.shell.exec_script(os.path.join(self.src, s))
def _run_code(self) -> None:
if self.dry_run:
print("will execute code:", self.code)
return
self.shell.exec_snippet(self.code)
def _run_commands(self) -> None:
if self.dry_run:
print("will execute:", self.commands)
return
for cmd in self.commands:
self.shell.exec_command(cmd)
class ContextManagerConfig(object):
def __init__(self, conf_file_path: str):
try:
with open(conf_file_path, 'rb') as f: self.config = tomllib.load(f)
except Exception as e:
raise ValueError('error parsing "{config_file_path}":', e)
def is_arr_of_str(x) -> bool:
return isinstance(x, list) and all(isinstance(s, str) for s in x)
def is_dict_of_str(x) -> bool:
return isinstance(x, dict) and all(isinstance(k, str) and isinstance(v, str) for k, v in x.items())
def user_path(s: str) -> str:
return os.path.expanduser(s)
if SECTION_GENERAL not in self.config:
raise ValueError(f'section "{SECTION_GENERAL}" not found in the config')
# Will extnsively check what's inside config.toml without additinal imports.
# Also, fuck dynamic typing!
self.tasks = self.config[SECTION_GENERAL].get(KEY_TASKS)
if not self.tasks or not is_arr_of_str(self.tasks):
raise ValueError(f'invalid config: key "{KEY_TASKS}" in section "{SECTION_GENERAL}" must be a non-empty array of strings')
if SECTION_TASK not in self.config: # valid case with all defaults
self.config[SECTION_TASK] = dict()
errors, err_count = list(), 0 # fuck it again
self.dry_run = self.config[SECTION_GENERAL].get(KEY_DRY_RUN) or False
if not isinstance(self.dry_run, bool):
err_count += 1
errors.append(f'{err_count}. key "{KEY_DRY_RUN}" in section "{SECTION_GENERAL} of the config must be bool"')
self.home = user_path(self.config[SECTION_GENERAL].get(KEY_CONTEXTS_PATH) or os.path.join(XDG_CONFIG_HOME, 'userctx'))
if not os.path.isabs(self.home):
err_count +=1
errors.append(f'{err_count}. path "{self.home}" in key "{KEY_CONTEXTS_PATH}" of section "{SECTION_GENERAL}" must be absolute or relative to home: "~/{self.home}"')
self.managed_path = user_path(self.config[SECTION_GENERAL].get(KEY_MANAGED_DIR) or XDG_CONFIG_HOME)
if not os.path.isabs(self.managed_path):
err_count +=1
errors.append(f'{err_count}. path "{self.managed_path}" in key "{KEY_MANAGED_DIR}" of section "{SECTION_GENERAL}" must be absolute or relative to home: "~/{self.managed_path}"')
tasks = self.config.get(SECTION_TASK)
for name, t in tasks.items():
errstr = f'for task "{name}"'
for k in list(t.keys()): # we might change t
v = t[k]
if k in [KEY_SRC_PATH, KEY_DST_PATH, KEY_CODE] and not isinstance(v, str):
err_count +=1
errors.append(f'{err_count}. {errstr} key "{k}" must be a string')
continue
elif k in [KEY_COMMAND, KEY_SCRIPT, KEY_RELOAD, KEY_ACTIONS, KEY_SYMLINK] and not isinstance(v, list):
err_count +=1
errors.append(f'{err_count}. {errstr} key "{k}" must be an array')
continue
if k == KEY_COMMAND and not all(is_arr_of_str(elem) for elem in v):
err_count +=1
errors.append(f'{err_count}. {errstr} all elements of "{KEY_COMMAND}" must be arrays of strings')
if k == KEY_SCRIPT and not is_arr_of_str(v):
err_count +=1
errors.append(f'{err_count}. {errstr} "{KEY_SCRIPT}" must be array of strings')
if k == KEY_SYMLINK and not all(is_dict_of_str(elem) for elem in v):
err_count +=1
errors.append(f'{err_count}. {errstr} all elements of "{KEY_SYMLINK}" array must be mappings (inline tables) of strings')
if k == KEY_ACTIONS and not is_arr_of_str(v):
err_count +=1
errors.append(f'{err_count}. {errstr} "{KEY_ACTIONS}" must be array if strings')
if k == KEY_ACTIONS and not all(a in ACTIONS_ALL for a in v):
err_count +=1
errors.append(f'{err_count}. {errstr} found invalid actions: {[a for a in v if a not in ACTIONS_ALL]} in key "{KEY_ACTIONS}", valid actions are: {ACTIONS_ALL}')
if k == KEY_RELOAD and not is_arr_of_str(v):
err_count +=1
errors.append(f'{err_count}. {errstr} "{KEY_RELOAD}" must be array of strings')
if k in [KEY_SRC_PATH, KEY_DST_PATH]:
p = user_path(v)
if not os.path.isabs(p):
err_count +=1
errors.append(f'{err_count}. {errstr} path "{v}" in key "{k}" must be either absolute or relative to home: "~/{v}"')
t[k] = p # substituting path (e.g. "~/somedir" -> "/home/user/somedir")
if errors:
print(f'{err_count} errors found in the config ({conf_file_path}):')
print('\n'.join(errors))
raise ValueError('invalid config')
def get_contexts_list(self) -> list[str]:
return [entry.name for entry in os.scandir(self.home) if entry.is_dir()]
def get_tasks_for_context(self, context_name: str) -> list[Task]:
ctx_path = os.path.join(self.home, context_name)
if not (os.path.exists(ctx_path) and os.path.isdir(ctx_path)):
raise ValueError(f'context directory "{context_name}" not found in "{self.home}"')
out = list()
for name in self.tasks:
task_path = os.path.join(ctx_path, name)
target_path = os.path.join(self.managed_path, name)
t = Task(name, task_path, target_path, self.config[SECTION_TASK].get(name) or dict(), dry_run=self.dry_run)
out.append(t)
return out
class ContextManager(object):
def __init__(self, config: ContextManagerConfig):
self.config = config
def get_contexts_list(self) -> str:
return '\n'.join(self.config.get_contexts_list())
def apply_context(self, context_name: str) -> None:
print(f'loading and applying context: "{context_name}"')
try:
tasks = self.config.get_tasks_for_context(context_name)
except Exception as e:
print(f'error getting tasks for context "{context_name}":', e)
return
if not tasks:
print(f'no tasks found for context "{context_name}"')
return
errors = list()
for task in tasks:
print(f'running task "{task.name}"...', end=' ')
try:
task.execute()
print("OK")
except Exception as e:
errors.append((task.name, e))
print('FAILED')
if errors:
print('Errors:')
for t in errors:
print(f'task: "{t[0]}", error: "{t[1]}"')
class Runner(object):
def __init__(self):
self.cmd = sys.argv[1] if len(sys.argv) > 1 else ''
self.context_name = ' '.join(sys.argv[2:]) if len(sys.argv) > 2 else ''
self.config_file_path = os.path.join(XDG_CONFIG_HOME, 'userctx', 'config.toml')
def run(self) -> int:
if self.cmd == '':
print(HELP_MESSAGE)
return 0
if self.cmd not in COMMANDS_ALL:
print(f'invalid command. valid commands are: {", ".join(COMMANDS_ALL)}')
return 1
if self.cmd in [COMMAND_LOAD, COMMAND_SAVE] and self.context_name == '':
print('invalid command. please provide name of context to apply')
return 2
try:
config = ContextManagerConfig(self.config_file_path)
except Exception as e:
print("error loading config:", e)
return 3
try:
ctxmgr = ContextManager(config)
if self.cmd == COMMAND_LIST:
print(ctxmgr.get_contexts_list())
elif self.cmd == COMMAND_LOAD:
ctxmgr.apply_context(self.context_name)
elif self.cmd == COMMAND_SAVE:
print('save not implemented')
except Exception as e:
print(f'error executing command "{self.cmd}":', e)
return 4
return 0
def main():
sys.exit(Runner().run())
if __name__ == '__main__':
main()