2019-01-29 16:19:46 -06:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
|
|
import argparse
|
2025-01-17 17:40:22 -06:00
|
|
|
from dataclasses import dataclass
|
2025-01-13 21:49:54 -06:00
|
|
|
from datetime import timedelta
|
2025-01-17 17:40:22 -06:00
|
|
|
from enum import Enum
|
2019-01-29 16:19:46 -06:00
|
|
|
import os
|
2019-04-13 01:53:21 -05:00
|
|
|
import shutil
|
2019-01-29 19:45:45 -06:00
|
|
|
import socket
|
2023-05-12 02:33:45 -05:00
|
|
|
import subprocess
|
2019-01-29 16:19:46 -06:00
|
|
|
import sys
|
2020-04-04 02:45:46 -05:00
|
|
|
from functools import partial
|
2019-01-29 16:19:46 -06:00
|
|
|
from pathlib import Path
|
2022-04-03 17:20:07 -05:00
|
|
|
from typing import List
|
2019-01-29 16:19:46 -06:00
|
|
|
|
2025-01-17 17:40:22 -06:00
|
|
|
from attrs import define, field
|
|
|
|
import cattrs
|
2025-04-04 20:30:54 -05:00
|
|
|
import jinja2
|
2025-01-13 21:49:54 -06:00
|
|
|
import requests_cache
|
2019-01-29 19:45:45 -06:00
|
|
|
import toml
|
2020-04-04 02:45:46 -05:00
|
|
|
import yaml
|
|
|
|
|
2025-01-13 21:49:54 -06:00
|
|
|
http_session = requests_cache.CachedSession(expire_after=timedelta(days=1))
|
|
|
|
|
|
|
|
BASE16_TEMPLATES_URL = "https://raw.githubusercontent.com/chriskempson/base16-templates-source/master/list.yaml"
|
|
|
|
BASE16_TEMPLATES = yaml.safe_load(http_session.get(BASE16_TEMPLATES_URL).text)
|
2020-04-04 02:45:46 -05:00
|
|
|
|
2022-04-02 13:28:09 -05:00
|
|
|
# Pending https://github.com/chriskempson/base16-templates-source/pull/106
|
2025-01-13 21:49:54 -06:00
|
|
|
BASE16_TEMPLATES["wofi-colors"] = "https://github.com/agausmann/base16-wofi-colors"
|
2022-04-02 13:28:09 -05:00
|
|
|
|
2020-04-04 02:45:46 -05:00
|
|
|
|
2025-01-13 21:49:54 -06:00
|
|
|
def get_base16(scheme, app, template="default"):
|
2022-04-02 13:28:09 -05:00
|
|
|
base_url = BASE16_TEMPLATES[app]
|
2025-01-13 21:49:54 -06:00
|
|
|
if "github.com" in base_url:
|
|
|
|
base_url = (
|
|
|
|
base_url.replace("github.com", "raw.githubusercontent.com") + "/master/"
|
|
|
|
)
|
2022-04-02 13:28:09 -05:00
|
|
|
else:
|
2025-01-13 21:49:54 -06:00
|
|
|
base_url += "/blob/master/"
|
|
|
|
config = yaml.safe_load(http_session.get(base_url + "templates/config.yaml").text)
|
|
|
|
output = config[template]["output"]
|
|
|
|
extension = config[template]["extension"]
|
|
|
|
return http_session.get(base_url + output + "/base16-" + scheme + extension).text
|
2019-01-29 19:45:45 -06:00
|
|
|
|
2019-01-29 16:19:46 -06:00
|
|
|
|
2022-04-03 17:20:07 -05:00
|
|
|
def is_outdated(src: List[Path], dst: Path) -> bool:
|
|
|
|
if not dst.exists():
|
|
|
|
return True
|
|
|
|
|
|
|
|
dst_modified = dst.stat().st_mtime
|
2025-01-13 21:49:54 -06:00
|
|
|
return any(a_src.stat().st_mtime > dst_modified for a_src in src if a_src.exists())
|
2022-04-03 17:20:07 -05:00
|
|
|
|
|
|
|
|
2025-01-17 17:40:22 -06:00
|
|
|
class BackgroundMode(Enum):
|
|
|
|
Fill = "fill"
|
|
|
|
|
|
|
|
@property
|
|
|
|
def sway_value(self) -> str:
|
|
|
|
match self:
|
|
|
|
case self.Fill:
|
|
|
|
return "fill"
|
|
|
|
|
|
|
|
@property
|
|
|
|
def wpaperd_value(self) -> str:
|
|
|
|
match self:
|
|
|
|
case self.Fill:
|
|
|
|
return "center"
|
|
|
|
|
|
|
|
|
|
|
|
@define
|
|
|
|
class InputConfig:
|
|
|
|
match: str
|
|
|
|
tap: bool | None = None
|
|
|
|
|
|
|
|
@property
|
|
|
|
def sway_lines(self) -> str:
|
|
|
|
lines = []
|
|
|
|
if self.tap is not None:
|
|
|
|
lines.append(f" tap {'enabled' if self.tap else 'disabled'}")
|
|
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
@define
|
|
|
|
class OutputConfig:
|
|
|
|
match: str
|
|
|
|
position: list | None = None
|
|
|
|
mode: str | None = None
|
|
|
|
scale: float | None = None
|
|
|
|
background_path: str | None = None
|
|
|
|
background_mode: BackgroundMode = BackgroundMode.Fill
|
2025-03-20 10:24:47 -05:00
|
|
|
port: str | None = None
|
2025-01-17 17:40:22 -06:00
|
|
|
|
|
|
|
@property
|
|
|
|
def sway_lines(self) -> str:
|
|
|
|
lines = []
|
|
|
|
if self.position:
|
|
|
|
lines.append(f" position {' '.join(map(str, self.position))}")
|
|
|
|
if self.mode:
|
|
|
|
lines.append(f" mode '{self.mode}'")
|
|
|
|
if self.background_path:
|
|
|
|
lines.append(
|
|
|
|
f" background '{self.background_path}' {self.background_mode.sway_value}"
|
|
|
|
)
|
|
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def swaylock_image_line(self) -> str | None:
|
2025-03-20 10:24:47 -05:00
|
|
|
if (self.match != "*" and self.port is None) or self.background_path is None:
|
2025-01-17 17:40:22 -06:00
|
|
|
return None
|
|
|
|
|
|
|
|
if self.match == "*":
|
|
|
|
return f"image={self.background_path}"
|
2025-03-20 10:24:47 -05:00
|
|
|
return f"image={self.port}:{self.background_path}"
|
2025-01-17 17:40:22 -06:00
|
|
|
|
|
|
|
@property
|
|
|
|
def niri_lines(self) -> str:
|
|
|
|
lines = []
|
|
|
|
if self.position:
|
|
|
|
lines.append(f" position x={self.position[0]} y={self.position[1]}")
|
|
|
|
if self.mode:
|
|
|
|
lines.append(f" mode \"{self.mode.removesuffix('Hz')}\"")
|
|
|
|
if self.scale:
|
|
|
|
lines.append(f" scale {self.scale}")
|
|
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def wpaperd_config_fragment(self) -> dict:
|
|
|
|
if self.background_path is None:
|
|
|
|
return {}
|
|
|
|
|
2025-04-04 20:01:26 -05:00
|
|
|
key = self.match
|
2025-03-20 10:24:47 -05:00
|
|
|
if self.match == "*":
|
2025-01-17 17:40:22 -06:00
|
|
|
key = "any"
|
|
|
|
|
|
|
|
return {
|
|
|
|
key: {
|
|
|
|
"path": self.background_path,
|
|
|
|
"mode": self.background_mode.wpaperd_value,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2025-02-08 15:36:14 -06:00
|
|
|
@define
|
|
|
|
class NiriConfig:
|
|
|
|
default_column_width: float = 0.5
|
|
|
|
|
|
|
|
|
2025-01-17 17:40:22 -06:00
|
|
|
@define
|
|
|
|
class HostConfig:
|
|
|
|
name: str
|
|
|
|
is_virtual: bool = False
|
|
|
|
base16_scheme: str = "seti"
|
|
|
|
wireless: list[str] = field(factory=list)
|
|
|
|
ethernet: list[str] = field(factory=list)
|
2025-03-25 19:59:10 -05:00
|
|
|
auto_ethernet: bool = True
|
2025-01-17 17:40:22 -06:00
|
|
|
disks: list[str] = field(factory=lambda: ["/"])
|
2025-02-08 09:09:26 -06:00
|
|
|
has_battery: bool = False
|
2025-01-17 17:40:22 -06:00
|
|
|
system_font: str = "Fira Sans"
|
|
|
|
system_mono_font: str = "Fira Mono"
|
|
|
|
temperature_path: str | None = None
|
|
|
|
terminal: str = "alacritty"
|
|
|
|
lock_cmd: str = "swaylock -c 000000"
|
|
|
|
display_on_cmd: str = "wlopm --on *"
|
|
|
|
display_off_cmd: str = "wlopm --off *"
|
|
|
|
use_jump_host: bool = False
|
|
|
|
inputs: list[InputConfig] = field(factory=list)
|
|
|
|
outputs: list[OutputConfig] = field(factory=list)
|
2025-02-08 15:36:14 -06:00
|
|
|
niri: NiriConfig = field(factory=NiriConfig)
|
2025-01-17 17:40:22 -06:00
|
|
|
|
|
|
|
@property
|
|
|
|
def swaylock_images(self) -> str:
|
|
|
|
return "\n".join(
|
|
|
|
line for output in self.outputs if (line := output.swaylock_image_line)
|
|
|
|
)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def wpaperd_config(self) -> str:
|
|
|
|
config = {}
|
|
|
|
|
|
|
|
for output in self.outputs:
|
|
|
|
config.update(output.wpaperd_config_fragment)
|
|
|
|
|
|
|
|
return toml.dumps(config)
|
|
|
|
|
|
|
|
|
2019-01-29 16:19:46 -06:00
|
|
|
def main():
|
|
|
|
parser = argparse.ArgumentParser(
|
2025-01-13 21:49:54 -06:00
|
|
|
description="Generates and installs dotfiles for this host.",
|
2019-01-29 16:19:46 -06:00
|
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
|
|
)
|
|
|
|
parser.add_argument(
|
2025-01-13 21:49:54 -06:00
|
|
|
"-d",
|
|
|
|
"--dotfiles",
|
|
|
|
help="The base directory of the dotfiles repository.",
|
2019-01-29 16:19:46 -06:00
|
|
|
type=Path,
|
|
|
|
default=Path(sys.argv[0]).parent,
|
|
|
|
)
|
|
|
|
parser.add_argument(
|
2025-01-13 21:49:54 -06:00
|
|
|
"-n",
|
|
|
|
"--hostname",
|
|
|
|
help="The hostname or other identifying name of this system that will"
|
|
|
|
" be used to retrieve the host-specific configuration.",
|
|
|
|
default=os.environ.get("HOSTNAME") or socket.gethostname(),
|
2019-01-29 16:19:46 -06:00
|
|
|
)
|
|
|
|
parser.add_argument(
|
2025-01-13 21:49:54 -06:00
|
|
|
"-o",
|
|
|
|
"--home",
|
|
|
|
help="The home directory where generated dotfiles will be installed.",
|
2019-01-29 19:45:45 -06:00
|
|
|
type=Path,
|
2025-01-13 21:49:54 -06:00
|
|
|
default=os.environ.get("HOME") or Path.home(),
|
2019-01-29 16:19:46 -06:00
|
|
|
)
|
2022-04-03 17:20:07 -05:00
|
|
|
parser.add_argument(
|
2025-01-13 21:49:54 -06:00
|
|
|
"-f",
|
|
|
|
"--force",
|
|
|
|
help="Force overwrite all files even if they are not considered outdated.",
|
|
|
|
action="store_true",
|
2022-04-03 17:20:07 -05:00
|
|
|
)
|
2019-01-29 16:19:46 -06:00
|
|
|
args = parser.parse_args()
|
|
|
|
|
2025-02-08 09:34:39 -06:00
|
|
|
dotfiles_dir: Path = args.dotfiles
|
|
|
|
raw_dir = dotfiles_dir / "raw"
|
|
|
|
templates_dir = dotfiles_dir / "templates"
|
|
|
|
host_filename = dotfiles_dir / "hosts" / "{}.toml".format(args.hostname)
|
2019-01-29 19:45:45 -06:00
|
|
|
|
2025-01-17 17:40:22 -06:00
|
|
|
host_toml = {
|
|
|
|
"name": args.hostname,
|
|
|
|
}
|
2019-01-30 01:45:19 -06:00
|
|
|
if host_filename.exists():
|
|
|
|
with open(host_filename) as host_file:
|
2025-01-17 17:40:22 -06:00
|
|
|
host_toml.update(toml.load(host_file))
|
2023-05-12 02:33:45 -05:00
|
|
|
|
2025-01-17 17:40:22 -06:00
|
|
|
host_config = cattrs.structure(host_toml, HostConfig)
|
2023-05-12 02:33:45 -05:00
|
|
|
|
2025-01-17 17:40:22 -06:00
|
|
|
for output in host_config.outputs:
|
2025-03-20 10:24:47 -05:00
|
|
|
# Attempt to resolve port names for swaylock template
|
2023-05-12 02:33:45 -05:00
|
|
|
# (Workaround https://github.com/swaywm/swaylock/issues/114)
|
|
|
|
#
|
|
|
|
# This will only work if this is run on the target host
|
2025-01-17 17:40:22 -06:00
|
|
|
# and if sway or niri is running, but that is usually the case...
|
|
|
|
if output.match == "*":
|
|
|
|
continue
|
|
|
|
|
|
|
|
try:
|
|
|
|
if "SWAYSOCK" in os.environ:
|
|
|
|
get_outputs = subprocess.check_output(
|
|
|
|
["swaymsg", "-t", "get_outputs", "-p"],
|
|
|
|
).decode("utf-8")
|
|
|
|
for line in get_outputs.splitlines():
|
2025-03-20 10:24:47 -05:00
|
|
|
# Line format: Output <port> '<match identifier>'
|
2025-01-17 17:40:22 -06:00
|
|
|
if line.startswith("Output") and output.match in line:
|
2025-03-20 10:24:47 -05:00
|
|
|
output.port = line.split()[1]
|
2025-01-17 17:40:22 -06:00
|
|
|
break
|
|
|
|
|
|
|
|
elif "NIRI_SOCKET" in os.environ:
|
|
|
|
get_outputs = subprocess.check_output(
|
|
|
|
["niri", "msg", "outputs"],
|
|
|
|
).decode("utf-8")
|
|
|
|
for line in get_outputs.splitlines():
|
2025-03-20 10:24:47 -05:00
|
|
|
# Line format: Output "<match identifier>" (<port>)
|
2025-01-17 17:40:22 -06:00
|
|
|
if line.startswith("Output") and output.match in line:
|
2025-03-20 10:24:47 -05:00
|
|
|
output.port = (
|
2025-01-17 17:40:22 -06:00
|
|
|
line.split()[-1].removeprefix("(").removesuffix(")")
|
|
|
|
)
|
|
|
|
break
|
|
|
|
else:
|
|
|
|
print(
|
|
|
|
"Could not find SWAYSOCK or NIRI_SOCKET, cannot retrieve output names."
|
|
|
|
)
|
2025-02-08 09:09:26 -06:00
|
|
|
print("Please re-run in sway or niri to finish configuring swaylock.")
|
2025-01-17 17:40:22 -06:00
|
|
|
|
|
|
|
except subprocess.CalledProcessError:
|
|
|
|
print("Could not contact sway or niri to retrieve output names.")
|
|
|
|
print("Please re-run in sway or niri to finish configuring swaylock.")
|
2023-05-12 02:33:45 -05:00
|
|
|
|
2025-04-04 20:30:54 -05:00
|
|
|
jenv = jinja2.Environment(
|
|
|
|
loader=jinja2.FileSystemLoader("templates"),
|
|
|
|
autoescape=jinja2.select_autoescape(),
|
2019-01-29 19:45:45 -06:00
|
|
|
)
|
|
|
|
|
2025-02-08 09:34:39 -06:00
|
|
|
changed_paths = set()
|
|
|
|
|
2025-01-13 21:49:54 -06:00
|
|
|
for raw_path in raw_dir.glob("**/*"):
|
2019-04-13 01:53:21 -05:00
|
|
|
if not raw_path.is_file():
|
|
|
|
continue
|
|
|
|
rel_path = raw_path.relative_to(raw_dir)
|
|
|
|
output_path = args.home / rel_path
|
2022-04-03 17:20:07 -05:00
|
|
|
|
|
|
|
if args.force or is_outdated([raw_path], output_path):
|
|
|
|
print(rel_path)
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
shutil.copy(raw_path, output_path)
|
2025-02-08 09:34:39 -06:00
|
|
|
changed_paths.update(map(str, rel_path.parents))
|
2019-04-13 01:53:21 -05:00
|
|
|
|
2025-01-13 21:49:54 -06:00
|
|
|
for template_path in templates_dir.glob("**/*"):
|
2019-01-29 19:45:45 -06:00
|
|
|
if not template_path.is_file():
|
|
|
|
continue
|
2019-04-13 01:53:21 -05:00
|
|
|
rel_path = template_path.relative_to(templates_dir)
|
2025-04-04 20:30:54 -05:00
|
|
|
output_path: Path = args.home / template_path.relative_to(templates_dir)
|
|
|
|
output_path = output_path.with_name(output_path.name.removesuffix(".j2"))
|
2022-04-03 17:20:07 -05:00
|
|
|
|
|
|
|
if args.force or is_outdated([template_path, host_filename], output_path):
|
|
|
|
print(rel_path)
|
2025-04-04 20:30:54 -05:00
|
|
|
template = jenv.get_template(str(rel_path))
|
2022-04-03 17:20:07 -05:00
|
|
|
output = template.render(
|
|
|
|
host=host_config,
|
2022-04-03 19:09:57 -05:00
|
|
|
home=args.home,
|
2025-01-17 17:40:22 -06:00
|
|
|
get_base16=partial(get_base16, host_config.base16_scheme),
|
2022-04-03 17:20:07 -05:00
|
|
|
)
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
2025-01-13 21:49:54 -06:00
|
|
|
with open(output_path, "w+") as output_file:
|
2022-04-03 17:20:07 -05:00
|
|
|
output_file.write(output)
|
2019-01-29 16:19:46 -06:00
|
|
|
|
2023-04-11 13:58:20 -05:00
|
|
|
# Copy permissions from original file
|
2023-05-11 20:30:18 -05:00
|
|
|
output_path.chmod(template_path.stat().st_mode & 0o777)
|
2025-02-08 09:34:39 -06:00
|
|
|
changed_paths.update(map(str, rel_path.parents))
|
|
|
|
|
|
|
|
# Post-install hooks
|
|
|
|
if ".config/waybar" in changed_paths:
|
|
|
|
subprocess.call(["killall", "-USR2", "waybar"])
|
2023-04-11 13:58:20 -05:00
|
|
|
|
2019-01-29 16:19:46 -06:00
|
|
|
|
2025-01-13 21:49:54 -06:00
|
|
|
if __name__ == "__main__":
|
2020-04-04 02:45:46 -05:00
|
|
|
main()
|