mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-13 18:28:18 +02:00
Windows support
This commit is contained in:
1 parent
f1935a119b
commit
6cb2bd74cc
13 files changed
+207
-25
No files matched your search
@@ -19,9 +19,17 @@ AppName = "SaraSort"
|
||||
BundledFontFamily = "Fixedsys Excelsior"
|
||||
ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme
|
||||
|
||||
IsWindows = sys.platform == "win32"
|
||||
|
||||
# Each system's own per-user folders, like SaraSuite's: ~/.config and
|
||||
# ~/.local/share on Linux, %APPDATA% and %LOCALAPPDATA% on Windows.
|
||||
Home = Path.home()
|
||||
ConfigHome = Home / ".config"
|
||||
DataHome = Home / ".local" / "share"
|
||||
if IsWindows:
|
||||
ConfigHome = Path(os.environ.get("APPDATA") or Home / "AppData" / "Roaming")
|
||||
DataHome = Path(os.environ.get("LOCALAPPDATA") or Home / "AppData" / "Local")
|
||||
else:
|
||||
ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config")
|
||||
DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share")
|
||||
|
||||
SharedConfigDir = ConfigHome / "SaraSuite"
|
||||
ThemesDir = SharedConfigDir / "Themes"
|
||||
@@ -100,10 +108,13 @@ class AppConfig:
|
||||
HideWindowTitleBar: bool = False
|
||||
# Where SaraSuite installs SaraSortd: GlobalConf.toml and the two
|
||||
# directory config templates live in SortdConfigDir, SaraSortd.py in
|
||||
# SortdAppDir, and it runs as a systemd user unit.
|
||||
SortdConfigDir: str = "~/.config/SaraSuite/SaraSortd"
|
||||
SortdAppDir: str = "~/.local/share/SaraSuite/Programs/SaraSortd/App"
|
||||
ServiceName: str = "SaraSortd.service"
|
||||
# SortdAppDir, and it runs as a systemd user unit (Linux) or a background
|
||||
# service started from the Startup folder (Windows; see Service.py).
|
||||
SortdConfigDir: str = (str(ConfigHome / "SaraSuite" / "SaraSortd") if IsWindows
|
||||
else "~/.config/SaraSuite/SaraSortd")
|
||||
SortdAppDir: str = (str(DataHome / "SaraSuite" / "Programs" / "SaraSortd" / "App") if IsWindows
|
||||
else "~/.local/share/SaraSuite/Programs/SaraSortd/App")
|
||||
ServiceName: str = "SaraSortd" if IsWindows else "SaraSortd.service"
|
||||
# SaraSortd only reads GlobalConf.toml when it starts.
|
||||
AutoRestart: bool = True
|
||||
RefreshSeconds: int = 5 # status and log refresh on the Service page
|
||||
@@ -238,6 +249,8 @@ def ChildEnvironment() -> dict[str, str]:
|
||||
Environment.pop("LD_LIBRARY_PATH", None)
|
||||
for Key in ("PYTHONHOME", "PYTHONPATH"):
|
||||
Environment.pop(Key, None)
|
||||
if IsWindows:
|
||||
Environment["PYTHONUTF8"] = "1" # every Sara program reads and writes UTF-8
|
||||
return Environment
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import flet as ft
|
||||
from ConfigPage import ConfigPage
|
||||
from Core import (
|
||||
AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataHome, EffectiveSettings,
|
||||
LoadConfig, Palette,
|
||||
IsWindows, LoadConfig, Palette,
|
||||
)
|
||||
from FlowPage import FlowPage
|
||||
from ServicePage import ServicePage
|
||||
@@ -65,6 +65,8 @@ def Main(Page: ft.Page) -> None:
|
||||
Page.bgcolor = Palette.Base
|
||||
Page.window.width = 1180
|
||||
Page.window.height = 780
|
||||
if IsWindows:
|
||||
Page.window.icon = str(AssetsDir / "Icon.ico") # taskbar/title bar (Linux: the desktop entry)
|
||||
Page.window.min_width = 760
|
||||
Page.window.min_height = 520
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -1,6 +1,7 @@
|
||||
# Kept in sync between SaraSuite/Programs/SaraSort/ and SaraSort/Packaging/SaraSuite/.
|
||||
Name = "SaraSort"
|
||||
Parent = "SaraSortd" # a GUI for SaraSortd, listed under it
|
||||
Platforms = ["Linux", "Windows"]
|
||||
Description = "A GUI for configuring SaraSortd: a flowchart of what goes where, its service and logs, configs and templates."
|
||||
|
||||
[Source]
|
||||
@@ -11,17 +12,33 @@ Exclude = ["Packaging"]
|
||||
[Python]
|
||||
RequirementsFile = "requirements.txt"
|
||||
|
||||
# Linux: a launcher and a desktop entry.
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "Launch.sh"
|
||||
Target = "{AppDir}/Packaging/Launch.sh"
|
||||
Executable = true
|
||||
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "SaraSort.desktop"
|
||||
Target = "{ApplicationsDir}/SaraSort.desktop"
|
||||
|
||||
# Windows: a Start Menu shortcut (pythonw.exe Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "Windows"
|
||||
Name = "SaraSort"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# Made by SaraSort itself: Config.toml, Layout.toml, Templates/.
|
||||
[[Leftovers]]
|
||||
Path = "{ConfigDir}"
|
||||
Kind = "Config"
|
||||
Note = "SaraSort's settings, flowchart layout and your templates"
|
||||
|
||||
# Shared with the other Flet programs; see Programs/FletIdentity.py.
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "../../FletIdentity.py"
|
||||
Target = "{ProgramDir}/FletIdentity.py"
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Written by SaraSuite. Runs from the app folder so Flet finds assets/.
|
||||
# A Flet client copy with the window app ID srhtnin.{Name}, so the desktop
|
||||
# matches the window to {Name}.desktop (StartupWMClass) instead of showing it
|
||||
# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client.
|
||||
export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")"
|
||||
cd "{AppDir}" || exit 1
|
||||
exec "{Python}" Main.py "$@"
|
||||
@@ -7,3 +7,4 @@ Icon={AppDir}/assets/Icon.png
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
StartupNotify=true
|
||||
StartupWMClass=srhtnin.{Name}
|
||||
+107
-4
@@ -1,11 +1,22 @@
|
||||
"""SaraSortd's systemd user unit: status, start/stop/restart, autostart, and
|
||||
its journal. SaraSortd's own log files are read from its LogDir."""
|
||||
"""SaraSortd as a background service: status, start/stop/restart, autostart,
|
||||
and its journal. SaraSortd's own log files are read from its LogDir.
|
||||
|
||||
Linux: its systemd user unit (systemctl / journalctl).
|
||||
Windows: SaraSuite's stand-in for a systemd service, used the same way here:
|
||||
- it runs windowless as `pythonw.exe ServiceRun.py SaraSortd SaraSortd.py`
|
||||
from SaraSortd's config folder, and ServiceRun.py records its PID in
|
||||
%LOCALAPPDATA%\\SaraSuite\\Services\\SaraSortd.pid (that's how "running" is told);
|
||||
- autostart is a shortcut to that command in the Startup folder.
|
||||
Unit is the systemd unit on Linux and the service name ("SaraSortd") on Windows.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from Core import ChildEnvironment
|
||||
from Core import ChildEnvironment, ConfigHome, DataHome, IsWindows, LoadConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -22,11 +33,98 @@ class ServiceStatus:
|
||||
return self.Active in ("active", "activating", "reloading")
|
||||
|
||||
|
||||
_NoWindow = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
|
||||
def _Run(Arguments: list[str], Timeout: int = 15) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(Arguments, capture_output=True, text=True, timeout=Timeout, env=ChildEnvironment())
|
||||
return subprocess.run(Arguments, capture_output=True, text=True, timeout=Timeout, env=ChildEnvironment(),
|
||||
creationflags=_NoWindow)
|
||||
|
||||
|
||||
# --- Windows ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _PidFile(Name: str) -> Path:
|
||||
return DataHome / "SaraSuite" / "Services" / f"{Name}.pid"
|
||||
|
||||
|
||||
def _StartupLink(Name: str) -> Path:
|
||||
return ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / f"{Name}.lnk"
|
||||
|
||||
|
||||
def _WindowsCommand() -> tuple[Path, list[str], Path]:
|
||||
"""(pythonw.exe, its arguments, working folder), from SaraSuite's install layout."""
|
||||
Config = LoadConfig()
|
||||
AppDir = Path(Config.SortdAppDir).expanduser()
|
||||
ProgramDir = AppDir.parent
|
||||
PythonW = ProgramDir / ".venv" / "Scripts" / "pythonw.exe"
|
||||
Arguments = ["-X", "utf8", str(ProgramDir / "ServiceRun.py"), Config.ServiceName, str(Config.SortdScriptPath)]
|
||||
return PythonW, Arguments, Path(Config.SortdConfigDir).expanduser()
|
||||
|
||||
|
||||
def _WindowsPid(Name: str) -> str:
|
||||
try:
|
||||
Pid = int(_PidFile(Name).read_text().strip())
|
||||
Output = _Run(["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"]).stdout
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
return ""
|
||||
return str(Pid) if re.search(rf'"{Pid}"', Output) else ""
|
||||
|
||||
|
||||
def _WindowsStatus(Name: str) -> ServiceStatus:
|
||||
PythonW, Arguments, _Folder = _WindowsCommand()
|
||||
Pid = _WindowsPid(Name)
|
||||
Since = ""
|
||||
if Pid:
|
||||
Since = datetime.fromtimestamp(_PidFile(Name).stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
return ServiceStatus(
|
||||
Active="active" if Pid else "inactive",
|
||||
Sub="running" if Pid else "dead",
|
||||
Enabled="enabled" if _StartupLink(Name).exists() else "disabled",
|
||||
Pid=Pid,
|
||||
Since=Since,
|
||||
Found=PythonW.is_file() and Path(Arguments[2]).is_file(),
|
||||
)
|
||||
|
||||
|
||||
def _WindowsControl(Name: str, Action: str) -> str:
|
||||
PythonW, Arguments, Folder = _WindowsCommand()
|
||||
try:
|
||||
if Action in ("stop", "restart"):
|
||||
Pid = _WindowsPid(Name)
|
||||
if Pid:
|
||||
_Run(["taskkill", "/PID", Pid, "/T", "/F"])
|
||||
_PidFile(Name).unlink(missing_ok=True)
|
||||
if Action in ("start", "restart"):
|
||||
if _WindowsPid(Name):
|
||||
return ""
|
||||
Folder.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.Popen(
|
||||
[str(PythonW), *Arguments], cwd=str(Folder), env=ChildEnvironment(), close_fds=True,
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0) | _NoWindow,
|
||||
)
|
||||
elif Action == "enable":
|
||||
import pylnk3
|
||||
|
||||
Link = _StartupLink(Name)
|
||||
Link.parent.mkdir(parents=True, exist_ok=True)
|
||||
Quoted = " ".join(f'"{Item}"' if " " in Item or "\\" in Item else Item for Item in Arguments)
|
||||
pylnk3.for_file(str(PythonW), str(Link), arguments=Quoted, work_dir=str(Folder),
|
||||
description=f"{Name} (background service)")
|
||||
elif Action == "disable":
|
||||
_StartupLink(Name).unlink(missing_ok=True)
|
||||
except (OSError, subprocess.SubprocessError) as Error:
|
||||
return str(Error)
|
||||
return ""
|
||||
|
||||
|
||||
# --- Both -----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def Status(Unit: str) -> ServiceStatus:
|
||||
if IsWindows:
|
||||
return _WindowsStatus(Unit)
|
||||
try:
|
||||
Result = _Run(["systemctl", "--user", "show", Unit,
|
||||
"-p", "LoadState,ActiveState,SubState,UnitFileState,MainPID,ActiveEnterTimestamp"])
|
||||
@@ -45,6 +143,8 @@ def Status(Unit: str) -> ServiceStatus:
|
||||
|
||||
def Control(Unit: str, Action: str) -> str:
|
||||
"""Action: start, stop, restart, enable, disable. Returns "" or an error."""
|
||||
if IsWindows:
|
||||
return _WindowsControl(Unit, Action)
|
||||
try:
|
||||
Result = _Run(["systemctl", "--user", Action, Unit])
|
||||
except (OSError, subprocess.SubprocessError) as Error:
|
||||
@@ -60,6 +160,9 @@ def RestartIfRunning(Unit: str) -> str:
|
||||
|
||||
|
||||
def Journal(Unit: str, Lines: int) -> list[str]:
|
||||
if IsWindows:
|
||||
return ["Windows has no systemd journal. SaraSortd's own log files (the other log source) "
|
||||
"have everything it does."]
|
||||
try:
|
||||
Result = _Run(["journalctl", "--user", "-u", Unit, "-n", str(Lines), "--no-pager", "-o", "short-iso"])
|
||||
except (OSError, subprocess.SubprocessError) as Error:
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
import flet as ft
|
||||
import tomlkit
|
||||
|
||||
from Core import ChipButton, ChipButtonStyle, DataHome, Heading, Note, Palette
|
||||
from Core import ChipButton, ChipButtonStyle, DataHome, Heading, IsWindows, Note, Palette
|
||||
from Service import Control, Journal, LogFiles, ReadTail, Status
|
||||
from Widgets import App
|
||||
|
||||
@@ -105,10 +105,10 @@ class ServicePage(ft.Column):
|
||||
self.StateText.value = Text
|
||||
self.StateText.color = Color
|
||||
self.Details.controls = [
|
||||
Note(f"Unit: {self.App.Config.ServiceName}"),
|
||||
Note(f"{'Service' if IsWindows else 'Unit'}: {self.App.Config.ServiceName}"),
|
||||
Note(f"Since: {Current.Since or '-'}"),
|
||||
Note(f"PID: {Current.Pid or '-'}"),
|
||||
Note(f"Unit file: {Current.Enabled}"),
|
||||
Note(f"{'Autostart (Startup folder)' if IsWindows else 'Unit file'}: {Current.Enabled}"),
|
||||
]
|
||||
self.StartButton.disabled = Current.Running or not Current.Found
|
||||
self.StopButton.disabled = not Current.Running
|
||||
|
||||
+34
-2
@@ -12,7 +12,7 @@ import tomlkit
|
||||
|
||||
from Core import (
|
||||
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, Home,
|
||||
ImportSettings, LoadSharedSettings, Palette, SharedSettings,
|
||||
ImportSettings, IsWindows, LoadSharedSettings, Palette, SharedSettings,
|
||||
)
|
||||
from Sortd import MajorVersion, Setup, SortdError
|
||||
from SortdTransfer import ApplyBundle, Bundle, BundleDirectories, ExportSortd, MissingDirectories, ReadBundleSafely
|
||||
@@ -29,8 +29,40 @@ def _Note(Text: str) -> ft.Text:
|
||||
return ft.Text(Text, color=Palette.Overlay0, size=12)
|
||||
|
||||
|
||||
def _WindowsFonts() -> set[str]:
|
||||
"""Font families from the system and per-user Fonts registry keys
|
||||
("Arial Bold (TrueType)" -> "Arial")."""
|
||||
import re
|
||||
import winreg
|
||||
|
||||
Families: set[str] = set()
|
||||
Key = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"
|
||||
for Root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
|
||||
try:
|
||||
with winreg.OpenKey(Root, Key) as Fonts:
|
||||
Index = 0
|
||||
while True:
|
||||
try:
|
||||
Name = winreg.EnumValue(Fonts, Index)[0]
|
||||
except OSError:
|
||||
break
|
||||
Index += 1
|
||||
Name = re.sub(r"\s*\(.*\)$", "", Name)
|
||||
Name = re.sub(r"\s+(Bold|Italic|Light|Semibold|SemiBold|Black|Regular|Medium|Thin)\b.*$", "", Name)
|
||||
if Name and "&" not in Name:
|
||||
Families.add(Name.strip())
|
||||
except OSError:
|
||||
continue
|
||||
return Families
|
||||
|
||||
|
||||
def _Fonts() -> list[str]:
|
||||
"""Installed font families (fontconfig), with the bundled font first."""
|
||||
"""Installed font families (fontconfig, or the registry on Windows), with
|
||||
the bundled font first."""
|
||||
if IsWindows:
|
||||
Families = _WindowsFonts()
|
||||
Families.discard(BundledFontFamily)
|
||||
return [BundledFontFamily] + sorted(Families, key=str.lower)
|
||||
try:
|
||||
Result = subprocess.run(
|
||||
["fc-list", ":", "family"], capture_output=True, text=True, timeout=5, env=ChildEnvironment(),
|
||||
|
||||
@@ -22,6 +22,11 @@ from typing import Any, Optional
|
||||
import tomlkit
|
||||
from tomlkit.items import AoT
|
||||
|
||||
|
||||
def _Slashes(PathText: str) -> str:
|
||||
"""Windows paths use "\\" (and configs may mix both); SaraSortd 7.5 names and compares with "/"."""
|
||||
return PathText.replace("\\", "/")
|
||||
|
||||
from Core import AppConfig
|
||||
|
||||
TemplateNames = {"In": "InputDirConf.toml", "Out": "OutputDirConf.toml"}
|
||||
@@ -276,20 +281,21 @@ class Setup:
|
||||
return Path(os.path.normpath(self.GlobalPath.parent / Parsed))
|
||||
|
||||
def FlatName(self, DirPath: str) -> str:
|
||||
"""SaraSortd's FlatName: "%RootDir%/Pictures/Cats" -> "Pictures_Cats"."""
|
||||
Root = self.ParsePath(str(self.Dirs.get("RootDir", ""))).rstrip("/")
|
||||
DirPath = DirPath.rstrip("/")
|
||||
"""SaraSortd's FlatName: "%RootDir%/Pictures/Cats" -> "Pictures_Cats".
|
||||
Like SaraSortd 7.5, "\\" counts as "/" and a drive's ":" is dropped (Windows)."""
|
||||
Root = _Slashes(self.ParsePath(str(self.Dirs.get("RootDir", "")))).rstrip("/")
|
||||
DirPath = _Slashes(DirPath).rstrip("/")
|
||||
if Root and DirPath.startswith(Root + "/"):
|
||||
Relative = DirPath[len(Root) + 1:]
|
||||
else:
|
||||
Relative = DirPath.strip("/") or os.path.basename(DirPath)
|
||||
return Relative.replace("/", "_")
|
||||
return Relative.replace(":", "").replace("/", "_")
|
||||
|
||||
def CentralConf(self, DirPath: str, Kind: str) -> Optional[Path]:
|
||||
"""SaraSortd 7.2: a config named in [DirConfs], or one in DirConfDir.
|
||||
None when the config lives inside the directory."""
|
||||
for Key, Value in (self.Data.get("DirConfs") or {}).items():
|
||||
if self.ParsePath(str(Key)).rstrip("/") == DirPath.rstrip("/"):
|
||||
if _Slashes(self.ParsePath(str(Key))).rstrip("/") == _Slashes(DirPath).rstrip("/"):
|
||||
if isinstance(Value, dict):
|
||||
Value = Value.get(Kind)
|
||||
if Value:
|
||||
@@ -338,7 +344,7 @@ class Setup:
|
||||
After = Setup(self.Config, NewDoc)
|
||||
Folder = After.CentralFolder()
|
||||
for InputPath in self.Inputs:
|
||||
if Folder and (Folder == Path(InputPath) or str(Folder).startswith(InputPath.rstrip("/") + "/")):
|
||||
if Folder and Folder.is_relative_to(Path(InputPath)):
|
||||
raise SortdError(f"{Folder} is inside the input {InputPath}; SaraSortd would sort its own configs away.")
|
||||
Plan: dict[Path, Path] = {}
|
||||
for DirPath in dict.fromkeys(self.Inputs + self.Outputs):
|
||||
@@ -919,7 +925,7 @@ class Setup:
|
||||
f"which SaraSortd {Daemon} doesn't know about. Update SaraSortd to 7.2."))
|
||||
if Central:
|
||||
for InputPath in Inputs:
|
||||
if Central == Path(InputPath) or str(Central).startswith(InputPath.rstrip("/") + "/"):
|
||||
if Central.is_relative_to(Path(InputPath)):
|
||||
Found.append(("Error", f"DirConfDir ({Central}) is inside the input {InputPath}, so SaraSortd "
|
||||
"would sort its own configs away."))
|
||||
if UsesOwnInputs and Daemon and not VersionAtLeast(Daemon, (7, 1)):
|
||||
|
||||
+5
-2
@@ -7,7 +7,7 @@ from typing import Callable, Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from Core import AppConfig, ChildEnvironment, ChipButton, Note, NotificationStyle, Palette
|
||||
from Core import AppConfig, ChildEnvironment, ChipButton, IsWindows, Note, NotificationStyle, Palette
|
||||
from Service import RestartIfRunning
|
||||
from Sortd import Setup, SortdError
|
||||
|
||||
@@ -69,7 +69,10 @@ class App:
|
||||
|
||||
def OpenFolder(self, PathText: str) -> None:
|
||||
try:
|
||||
subprocess.Popen(["xdg-open", PathText], env=ChildEnvironment(), start_new_session=True)
|
||||
if IsWindows:
|
||||
os.startfile(PathText) # Explorer
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", PathText], env=ChildEnvironment(), start_new_session=True)
|
||||
except OSError as Error:
|
||||
self.Notify(f"Couldn't open {PathText}: {Error}", "error")
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
@@ -1,3 +1,4 @@
|
||||
flet>=1.0,<2.0
|
||||
flet-desktop>=1.0,<2.0 # the desktop client; otherwise Flet fetches it on first launch and quits once
|
||||
tomlkit
|
||||
pylnk3; sys_platform == "win32" # Windows: SaraSortd's autostart is a Startup-folder shortcut
|
||||
Reference in new issue
Block a user