feat: first version
check / tests and shellcheck (push) Failing after 56s

This commit is contained in:
Felitendo committed 2026-10-02 10:39:46 +02:00
commit 3e98dd1e7e
48 files changed
+13025

No files matched your search

+617
View File
@@ -0,0 +1,617 @@
#!/usr/bin/env python3
#
# rdfeed-fetch: sign in to an RD Web Access feed and download its apps
#
# The feed is the one Windows subscribes to under "RemoteApp and Desktop
# Connections". Signing in takes NTLM, which curl no longer always has, so it
# is done here with nothing but the Python standard library.
#
# rdfeed-fetch --url URL --user USER [--domain DOMAIN] --out DIR
#
# The password comes on stdin. DIR gets feed.xml, apps.tsv (app, type, title,
# file types), rdp/<app>.rdp and icons/<app>.png. Progress goes to stdout, one
# tab-separated line each:
#
# workspace <name> the name the company gave the feed
# domain <domain> the Windows domain the server signed us in to
# app <app> <type> <title>
# skip <title> <reason>
#
# Exit codes: 0 done, 10 user name or password wrong, 11 server not reached,
# 12 no feed at that address, 2 bad arguments.
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
import argparse
import base64
import hmac
import html.parser
import http.client
import os
import re
import socket
import ssl
import struct
import sys
import time
import unicodedata
import urllib.parse
import xml.etree.ElementTree as ET
import zlib
EXIT_AUTH = 10
EXIT_NETWORK = 11
EXIT_NOFEED = 12
# WebFeedLogin.aspx hands back a forms ticket in its body, and the Windows
# client returns it as a cookie. The cookie name is not documented and differs
# between versions, so it goes out under every name in use. IIS reads its own.
FEED_AUTH_COOKIES = ("TSWAFeedAuthCookie", ".ASPXAUTH", "TSWAFeedAuth")
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
# The file types of the usual apps, for feeds that list none. Windows only
# offers "Open with" for the types the administrator entered, and most never
# enter any. Matched as whole words against the program, the title and the
# alias of an app.
KNOWN_TYPES = [
(("winword", "word"), "docx docm doc dotx dotm dot rtf odt"),
(("excel",), "xlsx xlsm xlsb xls xltx xltm csv ods"),
(("powerpnt", "powerpoint"), "pptx pptm ppt potx potm ppsx pps odp"),
(("onenote",), "one"),
(("outlook",), "msg"),
(("visio",), "vsdx vsd"),
(("msaccess", "access"), "accdb mdb"),
(("winproj", "project"), "mpp"),
(("mspub", "publisher"), "pub"),
(("acrobat", "acrord32", "acrord", "pdf24", "foxit"), "pdf"),
(("trueview", "dwgviewr", "autocad", "acad"), "dwg dxf"),
(("notepad",), "txt log"),
]
class FeedError(Exception):
code = EXIT_NOFEED
class AuthError(FeedError):
code = EXIT_AUTH
# ---------------------------------------------------------------- NTLMv2 (MS-NLMP)
def md4(data):
"""hashlib has no MD4 any more on OpenSSL 3, and NTLM needs it."""
def rol(v, s):
v &= 0xFFFFFFFF
return ((v << s) | (v >> (32 - s))) & 0xFFFFFFFF
msg = bytearray(data) + b"\x80"
while len(msg) % 64 != 56:
msg.append(0)
msg += struct.pack("<Q", (8 * len(data)) & 0xFFFFFFFFFFFFFFFF)
a, b, c, d = 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476
for off in range(0, len(msg), 64):
x = struct.unpack("<16I", msg[off:off + 64])
aa, bb, cc, dd = a, b, c, d
for i in (0, 4, 8, 12):
a = rol(a + ((b & c) | (~b & d)) + x[i], 3)
d = rol(d + ((a & b) | (~a & c)) + x[i + 1], 7)
c = rol(c + ((d & a) | (~d & b)) + x[i + 2], 11)
b = rol(b + ((c & d) | (~c & a)) + x[i + 3], 19)
for i in (0, 1, 2, 3):
a = rol(a + ((b & c) | (b & d) | (c & d)) + x[i] + 0x5A827999, 3)
d = rol(d + ((a & b) | (a & c) | (b & c)) + x[i + 4] + 0x5A827999, 5)
c = rol(c + ((d & a) | (d & b) | (a & b)) + x[i + 8] + 0x5A827999, 9)
b = rol(b + ((c & d) | (c & a) | (d & a)) + x[i + 12] + 0x5A827999, 13)
for i in (0, 2, 1, 3):
a = rol(a + (b ^ c ^ d) + x[i] + 0x6ED9EBA1, 3)
d = rol(d + (a ^ b ^ c) + x[i + 8] + 0x6ED9EBA1, 9)
c = rol(c + (d ^ a ^ b) + x[i + 4] + 0x6ED9EBA1, 11)
b = rol(b + (c ^ d ^ a) + x[i + 12] + 0x6ED9EBA1, 15)
a, b = (a + aa) & 0xFFFFFFFF, (b + bb) & 0xFFFFFFFF
c, d = (c + cc) & 0xFFFFFFFF, (d + dd) & 0xFFFFFFFF
return struct.pack("<4I", a, b, c, d)
# UNICODE | REQUEST_TARGET | NTLM | ALWAYS_SIGN | EXTENDED_SESSIONSECURITY |
# TARGET_INFO | VERSION | 128 | 56
NTLM_FLAGS = (0x00000001 | 0x00000004 | 0x00000200 | 0x00008000 | 0x00080000
| 0x00800000 | 0x02000000 | 0x20000000 | 0x80000000)
NTLM_VERSION = bytes([10, 0, 0x61, 0x4A, 0, 0, 0, 15])
def ntlm_negotiate():
return (b"NTLMSSP\x00" + struct.pack("<II", 1, NTLM_FLAGS | 0x00000002)
+ struct.pack("<HHI", 0, 0, 40) * 2 + NTLM_VERSION)
def ntlm_av_pairs(info):
pairs, i = {}, 0
while i + 4 <= len(info):
av_id, length = struct.unpack("<HH", info[i:i + 4])
i += 4
if av_id == 0:
break
pairs[av_id] = info[i:i + length]
i += length
return pairs
def ntowfv2(user, domain, password):
return hmac.new(md4(password.encode("utf-16-le")),
(user.upper() + domain).encode("utf-16-le"), "md5").digest()
def ntlmv2_response(key, server_challenge, client_challenge, timestamp, target_info):
temp = (b"\x01\x01" + b"\x00" * 6 + timestamp + client_challenge + b"\x00" * 4
+ target_info + b"\x00" * 4)
return hmac.new(key, server_challenge + temp, "md5").digest() + temp
def ntlm_authenticate(challenge, user, domain, password):
"""The AUTHENTICATE message. domain None takes the server's domain."""
if challenge[:8] != b"NTLMSSP\x00" or struct.unpack("<I", challenge[8:12])[0] != 2:
raise FeedError("invalid NTLM challenge")
server_flags = struct.unpack("<I", challenge[20:24])[0]
server_challenge = challenge[24:32]
info_len, _, info_off = struct.unpack("<HHI", challenge[40:48])
target_info = challenge[info_off:info_off + info_len]
av = ntlm_av_pairs(target_info)
if domain is None:
domain = av.get(2, b"").decode("utf-16-le")
key = ntowfv2(user, domain, password)
client_challenge = os.urandom(8)
timestamp = av.get(7) or struct.pack("<Q", int((time.time() + 11644473600) * 10_000_000))
nt_response = ntlmv2_response(key, server_challenge, client_challenge, timestamp, target_info)
# With a server timestamp the LM response is left empty (MS-NLMP 3.1.5.1.2).
if 7 in av:
lm_response = b"\x00" * 24
else:
lm_response = hmac.new(key, server_challenge + client_challenge, "md5").digest() + client_challenge
workstation = socket.gethostname().split(".")[0].upper()
fields = [lm_response, nt_response, domain.encode("utf-16-le"), user.encode("utf-16-le"),
workstation.encode("utf-16-le"), b""]
offset, secbufs, payload = 72, b"", b""
for field in fields:
secbufs += struct.pack("<HHI", len(field), len(field), offset)
payload += field
offset += len(field)
message = (b"NTLMSSP\x00" + struct.pack("<I", 3) + secbufs
+ struct.pack("<I", server_flags & NTLM_FLAGS) + NTLM_VERSION + payload)
return message, domain
# ---------------------------------------------------------------- HTTP
class HiddenInputs(html.parser.HTMLParser):
def __init__(self):
super().__init__()
self.fields, self.action = {}, None
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "form" and self.action is None:
self.action = a.get("action")
elif tag == "input" and (a.get("type") or "").lower() == "hidden" and a.get("name"):
self.fields[a["name"]] = a.get("value") or ""
class Session:
"""One keep-alive connection with cookies. NTLM signs in the connection,
not the request, so the handshake has to stay on one socket."""
def __init__(self, url, user, domain, password, timeout=30):
parts = urllib.parse.urlsplit(url)
self.scheme = parts.scheme
self.host = parts.hostname
self.port = parts.port
self.user, self.domain, self.password = user, domain, password
self.detected_domain = domain
self.timeout = timeout
self.cookies = {"TSWAFeatureCheckCookie": "true"}
self.conn = None
self.forms_tried = False
def close(self):
if self.conn:
self.conn.close()
self.conn = None
def _connect(self):
if self.scheme == "http":
return http.client.HTTPConnection(self.host, self.port, timeout=self.timeout)
return http.client.HTTPSConnection(self.host, self.port, timeout=self.timeout,
context=ssl.create_default_context())
def path(self, url, base="/"):
parts = urllib.parse.urlsplit(urllib.parse.urljoin(f"{self.scheme}://{self.host}{base}", url))
if parts.hostname and parts.hostname.lower() != self.host.lower():
raise FeedError(f"redirected to another server: {parts.hostname}")
return urllib.parse.urlunsplit(("", "", parts.path or "/", parts.query, ""))
def send(self, method, path, body=None, headers=None):
hdrs = {"User-Agent": "TSWorkspace/2.0", "Accept": "*/*", "Connection": "keep-alive"}
if self.cookies:
hdrs["Cookie"] = "; ".join(f"{k}={v}" for k, v in self.cookies.items())
hdrs.update(headers or {})
for attempt in range(2):
if self.conn is None:
self.conn = self._connect()
try:
self.conn.request(method, path, body=body, headers=hdrs)
resp = self.conn.getresponse()
data = resp.read()
break
except (http.client.HTTPException, OSError):
self.close()
if attempt:
raise
for name, value in resp.getheaders():
if name.lower() == "set-cookie":
cname, _, rest = value.partition("=")
cvalue = rest.split(";", 1)[0]
if cvalue and "01-jan-1970" not in value.lower():
self.cookies[cname.strip()] = cvalue
else:
self.cookies.pop(cname.strip(), None)
if (resp.getheader("Connection") or "").lower() == "close":
self.close()
return resp, data
def ntlm(self, method, path, body, headers):
auth = {"Authorization": "NTLM " + base64.b64encode(ntlm_negotiate()).decode()}
resp, data = self.send(method, path, body, {**(headers or {}), **auth})
challenge = next((v[5:] for k, v in resp.getheaders()
if k.lower() == "www-authenticate" and v.startswith("NTLM ")), None)
if resp.status != 401 or not challenge:
return resp, data
message, self.detected_domain = ntlm_authenticate(
base64.b64decode(challenge), self.user, self.domain, self.password)
auth = {"Authorization": "NTLM " + base64.b64encode(message).decode()}
return self.send(method, path, body, {**(headers or {}), **auth})
def fetch(self, url, method="GET", body=None, headers=None):
path = original = self.path(url)
token_retry = True
for _ in range(10):
resp, data = self.send(method, path, body, headers)
if resp.status == 401:
offered = " ".join(v.lower() for k, v in resp.getheaders()
if k.lower() == "www-authenticate")
if "ntlm" in offered:
resp, data = self.ntlm(method, path, body, headers)
if resp.status == 401:
raise AuthError("the server did not accept the user name or password")
content_type = (resp.getheader("Content-Type") or "").lower()
if resp.status == 200 and content_type.startswith("application/x-msts-webfeed-login"):
token = data.decode("ascii", "replace").strip()
for name in FEED_AUTH_COOKIES:
self.cookies[name] = token
if path != original and token_retry:
path, token_retry = original, False
continue
location = resp.getheader("Location")
if resp.status in (301, 302, 303, 307, 308) and location:
path = self.path(location, path)
if resp.status not in (307, 308):
method, body, headers = "GET", None, None
continue
return resp, data, path
raise FeedError("too many redirects")
def forms_login(self, login_path):
"""Some servers keep the .rdp files behind the RD Web form login."""
resp, data, path = self.fetch(login_path)
parser = HiddenInputs()
parser.feed(data.decode("utf-8", "replace"))
fields = dict(parser.fields)
user = self.user if "@" in self.user else f"{self.detected_domain}\\{self.user}"
fields.update({"DomainUserName": user, "UserPass": self.password,
"MachineType": "private", "isUtf8": "1"})
fields.setdefault("flags", "0")
action = self.path(parser.action or path, path)
resp, data, path = self.fetch(action, "POST", urllib.parse.urlencode(fields),
{"Content-Type": "application/x-www-form-urlencoded"})
if "login.aspx" in path.lower():
raise AuthError("the RD Web form login failed")
def download(self, url):
resp, data, path = self.fetch(url)
if "login.aspx" in path.lower() and not self.forms_tried:
self.forms_tried = True
self.forms_login(path)
resp, data, path = self.fetch(url)
if resp.status != 200:
raise FeedError(f"HTTP {resp.status}")
return data
# ---------------------------------------------------------------- The feed
def local_tag(el):
return el.tag.rsplit("}", 1)[-1]
def make_slug(text, taken):
# ä → a, é → e: the marks go, the letters stay.
text = "".join(c for c in unicodedata.normalize("NFKD", text) if not unicodedata.combining(c))
base = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:40].strip("-") or "app"
slug, n = base, 2
while slug in taken:
slug, n = f"{base}-{n}", n + 1
taken.add(slug)
return slug
def common_suffix(names):
"""The end all aliases share, like "-RemoteApps-CmsRdsh", from a dash on."""
if len(names) < 2:
return ""
suffix = os.path.commonprefix([n[::-1] for n in names])[::-1]
cut = suffix.find("-")
suffix = suffix[cut:] if cut >= 0 else ""
return suffix if all(len(n) > len(suffix) for n in names) else ""
def parse_feed(data):
"""The workspace name and its apps. Raises FeedError if this is no feed."""
try:
root = ET.fromstring(data)
except ET.ParseError:
raise FeedError("the server sent no feed")
if local_tag(root) != "ResourceCollection":
raise FeedError("the server sent no feed")
name = ""
apps = []
for el in root.iter():
tag = local_tag(el)
if tag == "Publisher" and not name:
name = (el.get("Name") or "").strip()
if tag != "Resource":
continue
rtype = el.get("Type") or "RemoteApp"
if rtype not in ("RemoteApp", "Desktop"):
continue
title = (el.get("Title") or "").strip()
alias = el.get("Alias") or title or el.get("ID") or "app"
icons, rdp_url, types = [], None, []
for sub in el.iter():
stag = local_tag(sub)
if stag == "FileExtension" and sub.get("Name"):
ext = sub.get("Name").strip().lstrip(".").lower()
if re.fullmatch(r"[a-z0-9_+-]+", ext) and ext not in types:
types.append(ext)
elif stag.startswith("Icon") and sub.get("FileURL"):
dims = re.findall(r"\d+", sub.get("Dimensions") or stag) or ["0"]
icons.append({"url": sub.get("FileURL"), "type": (sub.get("FileType") or "").lower(),
"size": int(dims[0])})
elif stag == "ResourceFile" and sub.get("URL") and rdp_url is None:
if (sub.get("FileExtension") or ".rdp").lower() == ".rdp":
rdp_url = sub.get("URL")
if rdp_url:
apps.append({"alias": alias, "title": title or alias, "type": rtype,
"rdp": rdp_url, "icons": icons, "types": types})
suffix = common_suffix([a["alias"] for a in apps])
taken = set()
for app in apps:
app["slug"] = make_slug(app["alias"][:len(app["alias"]) - len(suffix)], taken)
return name, apps
def known_types(*names):
"""The file types of a well-known app, from its program, title or alias."""
text = " ".join(names).lower()
for keys, types in KNOWN_TYPES:
if any(re.search(rf"(^|[^a-z0-9]){re.escape(k)}($|[^a-z0-9])", text) for k in keys):
return types.split()
return []
# ---------------------------------------------------------------- .rdp files
def decode_rdp(data):
if data.startswith(b"\xff\xfe") or data.startswith(b"\xfe\xff"):
return data.decode("utf-16")
if b"\x00" in data[:16]:
return data.decode("utf-16-le")
try:
return data.decode("utf-8-sig")
except UnicodeDecodeError:
return data.decode("latin-1")
def rdp_set(text, name, kind, value):
pattern = re.compile(rf"^{re.escape(name)}:[isb]:[^\r\n]*", re.IGNORECASE | re.MULTILINE)
line = f"{name}:{kind}:{value}"
if pattern.search(text):
return pattern.sub(line, text)
return text.rstrip("\r\n") + "\r\n" + line + "\r\n"
def rdp_del(text, name):
return re.sub(rf"^{re.escape(name)}:[isb]:[^\r\n]*\r?\n?", "", text, flags=re.IGNORECASE | re.MULTILINE)
def sanitize_rdp(text):
# One sign-in for the gateway and the session, and never a prompt: started
# from the app menu, there is no terminal to ask in.
text = rdp_set(text, "promptcredentialonce", "i", "1")
text = rdp_set(text, "prompt for credentials on client", "i", "0")
# Drive letters mean nothing here, and FreeRDP reads "*" as every mount,
# camera or USB device of this computer. Sharing a folder is a setting.
for name in ("drivestoredirect", "devicestoredirect", "camerastoredirect", "usbdevicestoredirect"):
text = rdp_del(text, name)
return text
# ---------------------------------------------------------------- Icons
def png_encode(width, height, rows):
"""RGBA rows, top down, as a PNG."""
def chunk(kind, payload):
return (struct.pack(">I", len(payload)) + kind + payload
+ struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF))
raw = b"".join(b"\x00" + bytes(row) for row in rows)
return (PNG_SIGNATURE + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
def dib_to_png(dib):
"""One bitmap frame of an .ico (BITMAPINFOHEADER, 1 to 32 bits) as a PNG."""
header, width, height2, _, bpp, compression = struct.unpack("<IiiHHI", dib[:20])
height = abs(height2) // 2
if width <= 0 or height <= 0 or compression not in (0, 3) or bpp not in (1, 4, 8, 24, 32):
return None
off = header + (12 if compression == 3 else 0)
palette = []
if bpp <= 8:
count = struct.unpack("<I", dib[32:36])[0] or (1 << bpp)
for i in range(count):
b, g, r = dib[off + 4 * i:off + 4 * i + 3]
palette.append((r, g, b))
off += 4 * count
stride = ((width * bpp + 31) // 32) * 4
pixels = dib[off:off + stride * height]
mask_stride = ((width + 31) // 32) * 4
mask = dib[off + stride * height:off + stride * height + mask_stride * height]
alpha = bpp == 32 and any(pixels[i] for i in range(3, len(pixels), 4))
rows = []
for y in range(height):
src = height - 1 - y
row = pixels[src * stride:(src + 1) * stride]
mrow = mask[src * mask_stride:(src + 1) * mask_stride]
out = bytearray()
for x in range(width):
if bpp == 32:
b, g, r, a = row[4 * x:4 * x + 4]
if not alpha:
a = 255
elif bpp == 24:
b, g, r = row[3 * x:3 * x + 3]
a = 255
else:
bit = x * bpp
index = (row[bit // 8] >> (8 - bpp - bit % 8)) & ((1 << bpp) - 1)
r, g, b = palette[index] if index < len(palette) else (0, 0, 0)
a = 255
if not alpha and x // 8 < len(mrow) and (mrow[x // 8] >> (7 - x % 8)) & 1:
a = 0
out += bytes((r, g, b, a))
rows.append(out)
return png_encode(width, height, rows)
def ico_to_png(data):
"""The biggest frame of an .ico, as a PNG. None if there is none to read."""
if data[:4] != b"\x00\x00\x01\x00":
return None
count = struct.unpack("<H", data[4:6])[0]
entries = []
for i in range(count):
entry = data[6 + 16 * i:22 + 16 * i]
if len(entry) < 16:
break
w, h, _, _, _, bpp, size, offset = struct.unpack("<BBBBHHII", entry)
entries.append(((w or 256) * (h or 256), bpp, size, offset))
for _, _, size, offset in sorted(entries, reverse=True):
frame = data[offset:offset + size]
if frame[:8] == PNG_SIGNATURE:
return frame
try:
png = dib_to_png(frame)
except (struct.error, ValueError, IndexError):
png = None
if png:
return png
return None
def save_icon(session, app, dest):
candidates = [i for i in app["icons"] if i["type"] == "ico"]
candidates += sorted((i for i in app["icons"] if i["type"] != "ico"),
key=lambda i: i["size"], reverse=True)
for icon in candidates:
try:
data = session.download(icon["url"])
except (FeedError, OSError, http.client.HTTPException):
continue
png = data if data[:8] == PNG_SIGNATURE else ico_to_png(data)
if png:
with open(dest, "wb") as f:
f.write(png)
return True
return False
# ---------------------------------------------------------------- Main
def say(*fields):
print("\t".join(str(f).replace("\t", " ").replace("\n", " ") for f in fields), flush=True)
def run(args, password):
session = Session(args.url, args.user, args.domain, password, args.timeout)
resp, data, _ = session.fetch(args.url)
if resp.status != 200:
raise FeedError(f"HTTP {resp.status}")
name, apps = parse_feed(data)
os.makedirs(os.path.join(args.out, "rdp"), exist_ok=True)
os.makedirs(os.path.join(args.out, "icons"), exist_ok=True)
with open(os.path.join(args.out, "feed.xml"), "wb") as f:
f.write(data)
say("workspace", name or session.host)
say("domain", session.detected_domain or "")
done = []
for app in apps:
try:
text = decode_rdp(session.download(app["rdp"]))
if "full address:s:" not in text.lower():
raise FeedError("not an RDP file")
with open(os.path.join(args.out, "rdp", app["slug"] + ".rdp"), "w") as f:
f.write(sanitize_rdp(text))
if not app["types"] and app["type"] == "RemoteApp":
program = re.search(r"^remoteapplicationprogram:s:(.*)$", text, re.IGNORECASE | re.MULTILINE)
app["types"] = known_types(program.group(1) if program else "", app["title"], app["alias"])
save_icon(session, app, os.path.join(args.out, "icons", app["slug"] + ".png"))
except (FeedError, OSError, http.client.HTTPException) as e:
say("skip", app["title"], e)
continue
done.append(app)
say("app", app["slug"], app["type"], app["title"])
with open(os.path.join(args.out, "apps.tsv"), "w") as f:
for app in done:
f.write("\t".join((app["slug"], app["type"], app["title"].replace("\t", " "),
" ".join(app["types"]))) + "\n")
def main(argv):
parser = argparse.ArgumentParser(prog="rdfeed-fetch", description="Download the apps of an RD Web Access feed.")
parser.add_argument("--url", required=True)
parser.add_argument("--user", required=True)
parser.add_argument("--domain", help="Windows domain; without it the server's own is used")
parser.add_argument("--out", required=True, help="directory for the downloaded files")
parser.add_argument("--timeout", type=float, default=30)
args = parser.parse_args(argv)
password = sys.stdin.readline().rstrip("\n")
try:
run(args, password)
except FeedError as e:
print(f"rdfeed-fetch: {e}", file=sys.stderr)
return e.code
except (OSError, http.client.HTTPException) as e:
print(f"rdfeed-fetch: {e}", file=sys.stderr)
return EXIT_NETWORK
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+146
View File
@@ -0,0 +1,146 @@
/*
* rdfeed-hook: works around two FreeRDP problems, loaded with LD_PRELOAD.
*
* 1. The second MFA prompt. A connection broker sends the client on to the
* server that hosts the session, so FreeRDP opens a second tunnel through
* the RD Gateway right away. With MFA on the gateway (the Azure MFA NPS
* extension), that second sign-in needs a second prompt. When it comes
* less than about 10 seconds after the first one was confirmed, no prompt
* is sent and the gateway waits forever. Windows does not run into this.
* So a new connection to port 443 waits a little first.
*
* RDFEED_GW_WAIT auto (the default), always or never. auto waits only
* when the first sign-in took long enough for somebody to
* confirm a prompt.
* RDFEED_GW_DELAY how long to wait, in seconds (12).
*
* Before waiting it prints "[rdfeed] gateway-delay <seconds>" to stderr,
* so rdfeed can tell the user a second prompt is coming.
*
* 2. The graphics pipeline. FreeRDP 3 always turns GFX on, there is no switch
* to turn it off, and in RemoteApp mode xfreerdp does not repaint a window
* after it was minimized or resized. With RDFEED_NO_GFX set, every attempt
* to turn it on is turned into off, and the classic drawing is used.
*
* It also makes stdout line buffered, so FreeRDP's log reaches rdfeed as it
* happens.
*
* Copyright (C) 2026 Felitendo
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#define _GNU_SOURCE
#include <dlfcn.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <time.h>
#include <unistd.h>
/* A pause this long between two connections means a new tunnel, not the
* second channel of the same one or an IPv6 to IPv4 fallback. */
#define NEW_TUNNEL_SECONDS 2.0
/* A first sign-in that took this long had a person confirming a prompt. */
#define MFA_SECONDS 2.5
static int (*real_connect)(int, const struct sockaddr *, socklen_t);
static int (*real_set_bool)(void *, int, int);
static double first_connect = -1;
static double last_connect = -1;
static double now(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
}
__attribute__((constructor)) static void init(void)
{
setvbuf(stdout, NULL, _IOLBF, 0);
}
/* RTLD_NEXT only finds libfreerdp3 when it was loaded globally, as it is in
* xfreerdp. Otherwise ask the library itself. */
static void *freerdp_sym(const char *name)
{
void *sym = dlsym(RTLD_NEXT, name);
if (!sym) {
void *lib = dlopen("libfreerdp3.so.3", RTLD_LAZY | RTLD_NOLOAD);
if (lib)
sym = dlsym(lib, name);
}
return sym;
}
static long gfx_key(void)
{
static long key = -2;
if (key == -2) {
key = -1;
if (getenv("RDFEED_NO_GFX")) {
ssize_t (*by_name)(const char *) =
(ssize_t (*)(const char *))freerdp_sym("freerdp_settings_get_key_for_name");
if (by_name)
key = (long)by_name("FreeRDP_SupportGraphicsPipeline");
}
}
return key;
}
int freerdp_settings_set_bool(void *settings, int id, int val)
{
if (!real_set_bool)
real_set_bool = (int (*)(void *, int, int))freerdp_sym("freerdp_settings_set_bool");
if (!real_set_bool)
return 0;
if (val && id == gfx_key())
val = 0;
return real_set_bool(settings, id, val);
}
static int should_wait(double t)
{
const char *mode = getenv("RDFEED_GW_WAIT");
if (last_connect < 0 || t - last_connect < NEW_TUNNEL_SECONDS)
return 0;
if (mode && strcmp(mode, "never") == 0)
return 0;
if (mode && strcmp(mode, "always") == 0)
return 1;
return t - first_connect >= MFA_SECONDS;
}
int connect(int fd, const struct sockaddr *addr, socklen_t len)
{
if (!real_connect)
real_connect = (int (*)(int, const struct sockaddr *, socklen_t))dlsym(RTLD_NEXT, "connect");
int port = -1;
if (addr && addr->sa_family == AF_INET)
port = ntohs(((const struct sockaddr_in *)addr)->sin_port);
else if (addr && addr->sa_family == AF_INET6)
port = ntohs(((const struct sockaddr_in6 *)addr)->sin6_port);
if (port == 443) {
double t = now();
if (first_connect < 0)
first_connect = t;
if (should_wait(t)) {
const char *env = getenv("RDFEED_GW_DELAY");
unsigned left = env ? (unsigned)atoi(env) : 12;
fprintf(stderr, "[rdfeed] gateway-delay %u\n", left);
fflush(stderr);
while (left)
left = sleep(left);
t = now();
}
last_connect = t;
}
return real_connect(fd, addr, len);
}
+202
View File
@@ -0,0 +1,202 @@
# shellcheck shell=bash
#
# Paths, translations and output helpers.
#
# Everything rdfeed keeps is the user's own: a settings file and one file per
# workspace under ~/.config/rdfeed, the downloaded apps under
# ~/.local/share/rdfeed, and the passwords in the keyring. Nothing needs root.
RF_VERSION="@VERSION@"
RF_NAME="rdfeed"
RF_PRETTY="rdfeed"
RF_LIBDIR="${RF_LIBDIR:-@LIBDIR@}"
RF_LIBEXECDIR="${RF_LIBEXECDIR:-@LIBEXECDIR@}"
RF_LOCALEDIR="${RF_LOCALEDIR:-@LOCALEDIR@}"
RF_FETCH="${RF_FETCH:-$RF_LIBEXECDIR/rdfeed-fetch}"
RF_HOOK="${RF_HOOK:-$RF_LIBEXECDIR/rdfeed-hook.so}"
RF_XDG_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}"
RF_XDG_DATA="${XDG_DATA_HOME:-$HOME/.local/share}"
RF_XDG_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}"
RF_CONFDIR="${RF_XDG_CONFIG}/${RF_NAME}"
RF_CONFIG="${RF_CONFDIR}/config"
RF_WSDIR="${RF_CONFDIR}/workspaces"
RF_DATADIR="${RF_XDG_DATA}/${RF_NAME}"
RF_CACHEDIR="${RF_XDG_CACHE}/${RF_NAME}"
# For what only lives while an app runs: the .rdp copy that names a file.
RF_RUNDIR="${XDG_RUNTIME_DIR:-$RF_CACHEDIR}/${RF_NAME}"
# Where the app menu looks: the entries, the folder for each workspace (a
# merged menu) and the folder's name and icon.
RF_APPDIR="${RF_XDG_DATA}/applications"
RF_MENUDIR="${RF_XDG_CONFIG}/menus/applications-merged"
RF_DIRDIR="${RF_XDG_DATA}/desktop-directories"
RF_UNIT_TIMER="rdfeed-refresh.timer"
# ---------------------------------------------------------------------------
# Translations
# ---------------------------------------------------------------------------
export TEXTDOMAIN="rdfeed"
export TEXTDOMAINDIR="${RF_LOCALEDIR}"
rf_ui_locale() {
local l="${RF_UI_LOCALE:-}"
if [[ -z $l ]]; then
l="${LC_ALL:-}"
[[ -z $l ]] && l="${LC_MESSAGES:-}"
[[ -z $l ]] && l="${LANG:-}"
fi
# systemd writes /etc/locale.conf and most distributions use it; Debian and
# Ubuntu keep the same LANG= line in /etc/default/locale instead.
if [[ -z $l ]]; then
local f
for f in /etc/locale.conf /etc/default/locale; do
[[ -r $f ]] || continue
l="$(sed -n 's/^LANG=//p' "$f" | tr -d '"' | head -n1)"
[[ -n $l ]] && break
done
fi
printf '%s\n' "${l:-C}"
}
# Every gettext lookup is a fork and the settings screen redraws a screenful of
# labels per keypress, so results are memoized.
declare -A RF_MSG_CACHE=()
RF_MSG_RESULT=''
# rf_msg_into <locale> <msgid>
# Plain lookup with the result in RF_MSG_RESULT and no printf formatting, for
# callers that would otherwise pay a fork per label per frame.
rf_msg_into() {
local locale="$1" msgid="$2" cachekey
cachekey="${locale}"$'\x1f'"${msgid}"
if [[ -n ${RF_MSG_CACHE[$cachekey]+set} ]]; then
RF_MSG_RESULT="${RF_MSG_CACHE[$cachekey]}"
return 0
fi
RF_MSG_RESULT="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)"
[[ -n $RF_MSG_RESULT ]] || RF_MSG_RESULT="$msgid"
RF_MSG_CACHE[$cachekey]="$RF_MSG_RESULT"
return 0
}
# rf_msg_in <locale> <msgid> [printf args...]
rf_msg_in() {
local locale="$1" msgid="$2"
shift 2
rf_msg_into "$locale" "$msgid"
# With no arguments the message is plain text, not a format string. Feeding
# it to printf anyway would turn a literal percent sign in a translation
# into an invalid conversion.
if (( $# == 0 )); then
printf '%s' "$RF_MSG_RESULT"
return
fi
# shellcheck disable=SC2059 # the format string is the translated message
printf -- "$RF_MSG_RESULT" "$@"
}
RF_LOCALE_CACHED=''
# rf_msg <msgid> [printf args...]
rf_msg() {
[[ -n $RF_LOCALE_CACHED ]] || RF_LOCALE_CACHED="$(rf_ui_locale)"
rf_msg_in "$RF_LOCALE_CACHED" "$@"
}
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
# Decided once, while stdout is still whatever the process was started with:
# testing -t 1 at the point of use is wrong for anything called through $(...),
# which sees a pipe and would conclude nobody is watching.
RF_INTERACTIVE=''
[[ -t 1 ]] && RF_INTERACTIVE=1
if [[ -n $RF_INTERACTIVE && -z ${NO_COLOR:-} ]]; then
RF_C_RESET=$'\033[0m'
RF_C_BOLD=$'\033[1m'
RF_C_DIM=$'\033[2m'
RF_C_BLUE=$'\033[38;2;52;153;255m'
RF_C_GREEN=$'\033[32m'
RF_C_YELLOW=$'\033[33m'
RF_C_RED=$'\033[31m'
else
RF_C_RESET='' RF_C_BOLD='' RF_C_DIM='' RF_C_BLUE=''
RF_C_GREEN='' RF_C_YELLOW='' RF_C_RED=''
fi
# What rf_ok, rf_bad and rf_note printed. The menu redraws straight after an
# action, which wipes the screen, so it shows these again under the new frame
# rather than holding everything up for a key press.
RF_UI_NOTICES=()
rf_say() { printf '%s\n' "$*"; }
rf_head() { printf '\n%s%s%s\n\n' "$RF_C_BOLD$RF_C_BLUE" "$*" "$RF_C_RESET"; }
rf_ok() { RF_UI_NOTICES+=("$RF_C_GREEN✔$RF_C_RESET $*"); printf '%s✔%s %s\n' "$RF_C_GREEN" "$RF_C_RESET" "$*"; }
rf_bad() { RF_UI_NOTICES+=("$RF_C_RED✘$RF_C_RESET $*"); printf '%s✘%s %s\n' "$RF_C_RED" "$RF_C_RESET" "$*" >&2; }
rf_note() { RF_UI_NOTICES+=("$RF_C_DIM•$RF_C_RESET $*"); printf '%s•%s %s\n' "$RF_C_DIM" "$RF_C_RESET" "$*"; }
rf_have() { command -v "$1" > /dev/null 2>&1; }
# rf_notify <summary> <body> [icon]
# A desktop notification, for what happens after the menu or the app menu
# started something and nobody watches a terminal.
rf_notify() {
rf_have notify-send || return 0
notify-send -a "$RF_PRETTY" -i "${3:-rdfeed}" -- "$1" "$2" > /dev/null 2>&1 || true
}
# Human-readable "x minutes ago" for a unix timestamp. 0 or empty yields the
# translated "never".
#
# Written with [[ ]] and a variable for each number on purpose: xgettext reads
# the < of an (( )) as a redirection and loses every string after it.
rf_time_ago() {
local ts="$1" now delta n
if [[ ! $ts =~ ^[0-9]+$ || $ts -eq 0 ]]; then
rf_msg "never"
printf '\n'
return
fi
now="$(date +%s)"
delta=$(( now - ts ))
[[ $delta -lt 0 ]] && delta=0
if [[ $delta -lt 60 ]]; then
rf_msg "just now"
elif [[ $delta -lt 120 ]]; then
rf_msg "1 minute ago"
elif [[ $delta -lt 3600 ]]; then
n=$(( delta / 60 ))
rf_msg "%d minutes ago" "$n"
elif [[ $delta -lt 7200 ]]; then
rf_msg "1 hour ago"
elif [[ $delta -lt 86400 ]]; then
n=$(( delta / 3600 ))
rf_msg "%d hours ago" "$n"
elif [[ $delta -lt 172800 ]]; then
rf_msg "1 day ago"
else
n=$(( delta / 86400 ))
rf_msg "%d days ago" "$n"
fi
printf '\n'
}
+155
View File
@@ -0,0 +1,155 @@
# shellcheck shell=bash
#
# Reading and writing the settings files.
#
# ~/.config/rdfeed/config holds how the apps run (a shared folder, the size,
# the graphics), and ~/.config/rdfeed/workspaces/<id> where a workspace comes
# from and who signs in to it. Neither is meant to be edited by hand: every
# option is in the menu.
#
# Both are parsed rather than sourced. One "Key=Value" per line, '#' comments.
declare -A RF_KV_CACHE=()
# _rf_kv_lookup <file> <Key> [default]
# Result in RF_KV_VALUE. Assigning rather than printing matters on the
# settings screen, which reads every key on every frame: a command
# substitution there is a fork, and forks are the whole cost of a redraw.
RF_KV_VALUE=''
_rf_kv_lookup() {
local file="$1" key="$2" default="${3:-}" val='' line content
RF_KV_VALUE="$default"
[[ -r $file ]] || return 0
if [[ -n ${RF_KV_CACHE[$file]+set} ]]; then
content="${RF_KV_CACHE[$file]}"
else
content="$(< "$file")"
RF_KV_CACHE[$file]="$content"
fi
while IFS= read -r line; do
[[ $line == *"$key"* ]] || continue
[[ $line =~ ^[[:space:]]*"$key"[[:space:]]*=(.*)$ ]] || continue
val="${BASH_REMATCH[1]}"
done <<< "$content"
val="${val#"${val%%[![:space:]]*}"}"
val="${val%"${val##*[![:space:]]}"}"
[[ -n $val ]] && RF_KV_VALUE="$val"
return 0
}
rf_is_true() {
case "${1,,}" in
yes|y|true|1|on|enabled) return 0 ;;
*) return 1 ;;
esac
}
# rf_kv_set <file> <Key> <Value> <header line>
rf_kv_set() {
local file="$1" key="$2" value="$3" header="$4" tmp
if [[ ! -e $file ]]; then
mkdir -p "$(dirname "$file")" || return 1
{
printf '# %s\n' "$header"
printf '#\n'
# shellcheck disable=SC2016 # the backticks are text
printf '# Written by `%s`. Nothing here needs editing by hand:\n' "$RF_NAME"
printf '# every option is in the menu.\n'
} > "$file" || return 1
fi
[[ -w $file ]] || return 1
tmp="$(mktemp "${file}.XXXXXX")" || return 1
chmod --reference="$file" "$tmp" 2>/dev/null || chmod 0644 "$tmp"
if grep -qE "^[[:space:]]*#?[[:space:]]*${key}[[:space:]]*=" "$file"; then
awk -v key="$key" -v value="$value" '
!done && $0 ~ "^[[:space:]]*#?[[:space:]]*" key "[[:space:]]*=" {
print key "=" value; done = 1; next
}
# drop any further occurrences so the file cannot grow duplicates
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" { next }
{ print }
' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
else
cat "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
printf '%s=%s\n' "$key" "$value" >> "$tmp"
fi
mv -f "$tmp" "$file"
unset 'RF_KV_CACHE[$file]'
}
# ---------------------------------------------------------------------------
# The settings
# ---------------------------------------------------------------------------
rf_config_get() {
_rf_kv_lookup "$RF_CONFIG" "$@"
printf '%s\n' "$RF_KV_VALUE"
}
rf_config_set() {
rf_kv_set "$RF_CONFIG" "$1" "$2" "$RF_PRETTY"
}
# rf_config_load
# Everything starting an app needs, resolved once.
rf_config_load() {
RF_KV_CACHE=()
_rf_kv_lookup "$RF_CONFIG" Share off; CFG_SHARE="$RF_KV_VALUE"
_rf_kv_lookup "$RF_CONFIG" Scale auto; CFG_SCALE="$RF_KV_VALUE"
_rf_kv_lookup "$RF_CONFIG" Graphics gfx; CFG_GRAPHICS="$RF_KV_VALUE"
_rf_kv_lookup "$RF_CONFIG" MfaWait auto; CFG_MFAWAIT="$RF_KV_VALUE"
_rf_kv_lookup "$RF_CONFIG" Sound yes; CFG_SOUND=no; rf_is_true "$RF_KV_VALUE" && CFG_SOUND=yes
return 0
}
# ---------------------------------------------------------------------------
# Workspaces
# ---------------------------------------------------------------------------
rf_ws_file() { printf '%s/%s\n' "$RF_WSDIR" "$1"; }
# rf_ws_ids
# The workspaces, sorted, in RF_WS_IDS.
RF_WS_IDS=()
rf_ws_ids() {
local f
RF_WS_IDS=()
[[ -d $RF_WSDIR ]] || return 0
for f in "$RF_WSDIR"/*; do
[[ -f $f ]] || continue
RF_WS_IDS+=("${f##*/}")
done
}
# rf_ws_load <id>
# Sets WS_URL, WS_NAME, WS_USER, WS_DOMAIN, WS_REFRESHED and WS_ACCOUNT, the
# name the way Windows writes it (DOMAIN\user, or user@domain as typed).
rf_ws_load() {
local file
file="$(rf_ws_file "$1")"
_rf_kv_lookup "$file" Url; WS_URL="$RF_KV_VALUE"
_rf_kv_lookup "$file" Name "$1"; WS_NAME="$RF_KV_VALUE"
_rf_kv_lookup "$file" User; WS_USER="$RF_KV_VALUE"
_rf_kv_lookup "$file" Domain; WS_DOMAIN="$RF_KV_VALUE"
_rf_kv_lookup "$file" Refreshed 0; WS_REFRESHED="$RF_KV_VALUE"
if [[ -n $WS_DOMAIN && $WS_USER != *@* ]]; then
WS_ACCOUNT="$WS_DOMAIN\\$WS_USER"
else
WS_ACCOUNT="$WS_USER"
fi
}
rf_ws_set() {
rf_kv_set "$(rf_ws_file "$1")" "$2" "$3" "$RF_PRETTY workspace"
}
+307
View File
@@ -0,0 +1,307 @@
# shellcheck shell=bash
#
# Starting an app.
#
# Each app runs in xfreerdp in RemoteApp mode: only its own windows show, not
# a whole Windows desktop. The arguments, the password among them, go to
# FreeRDP on stdin (/args-from:stdin), never on its command line.
#
# A file from this computer (an "Open with", or `rdfeed run APP FILE`) gets
# there the way Windows does it: its folder is shared as a drive, and the app
# is started with the path on that drive, \\tsclient\<folder>\<file>.
#
# rdfeed-hook.so goes into FreeRDP with LD_PRELOAD. It waits before the second
# gateway sign-in so a second MFA prompt is sent, and keeps the graphics
# pipeline off, which leaves windows unpainted in RemoteApp mode. See
# src/hook/rdfeed-hook.c.
# How long the hook waits before the second sign-in, in seconds.
RF_GW_DELAY=12
# rf_freerdp
# The FreeRDP 3 client that can show RemoteApps (the X11 one), in RF_FREERDP.
# Arch and Debian call it xfreerdp3, Fedora xfreerdp.
RF_FREERDP=''
rf_freerdp() {
RF_FREERDP=''
if rf_have xfreerdp3; then
RF_FREERDP=xfreerdp3
elif rf_have xfreerdp && [[ $(xfreerdp /version 2>/dev/null) == *" 3."* ]]; then
RF_FREERDP=xfreerdp
fi
[[ -n $RF_FREERDP ]]
}
# rf_kbd_layout
# The Windows id of the keyboard layout in use. Under Wayland, X11 programs
# often see "us" whatever the real layout is, so it is asked of the desktop.
rf_kbd_layout() {
local layout=''
if [[ -r $RF_XDG_CONFIG/kxkbrc ]]; then
layout="$(sed -n 's/^LayoutList=//p' "$RF_XDG_CONFIG/kxkbrc" | head -n1)"
fi
if [[ -z $layout && ${XDG_CURRENT_DESKTOP:-} == *GNOME* ]] && rf_have gsettings; then
layout="$(gsettings get org.gnome.desktop.input-sources sources 2>/dev/null \
| sed -n "s/^[^']*'xkb', '\([a-z]*\).*/\1/p")"
fi
if [[ -z $layout ]] && rf_have localectl; then
layout="$(localectl status 2>/dev/null | sed -n 's/.*X11 Layout: *//p')"
fi
[[ -z $layout ]] && layout="${XKB_DEFAULT_LAYOUT:-}"
layout="${layout%%,*}"
case "${layout,,}" in
de|at) printf '0x00000407\n' ;;
ch) printf '0x00000807\n' ;;
us) printf '0x00000409\n' ;;
gb|uk) printf '0x00000809\n' ;;
fr) printf '0x0000040C\n' ;;
be) printf '0x0000080C\n' ;;
es) printf '0x0000040A\n' ;;
it) printf '0x00000410\n' ;;
nl) printf '0x00000413\n' ;;
pl) printf '0x00000415\n' ;;
pt) printf '0x00000816\n' ;;
br) printf '0x00000416\n' ;;
ru) printf '0x00000419\n' ;;
ua) printf '0x00000422\n' ;;
tr) printf '0x0000041F\n' ;;
jp) printf '0x00000411\n' ;;
kr) printf '0x00000412\n' ;;
se) printf '0x0000041D\n' ;;
no) printf '0x00000414\n' ;;
dk) printf '0x00000406\n' ;;
fi) printf '0x0000040B\n' ;;
cz) printf '0x00000405\n' ;;
sk) printf '0x0000041B\n' ;;
hu) printf '0x0000040E\n' ;;
*) return 1 ;;
esac
}
# rf_auto_scale
# How much the desktop scales X11 programs that scale themselves, in percent,
# in RF_AUTO_SCALE. The apps are then drawn that much bigger on the server, so
# they come out sharp and the right size.
RF_AUTO_SCALE=100
rf_auto_scale() {
local s dpi
RF_AUTO_SCALE=100
s="$(awk -F= '/^\[/ { section = $0 } section == "[Xwayland]" && $1 == "Scale" { print $2 }' \
"$RF_XDG_CONFIG/kwinrc" 2>/dev/null | tail -n1)"
if [[ $s =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
RF_AUTO_SCALE="$(awk -v s="$s" 'BEGIN { printf "%d", s * 100 + 0.5 }')"
elif rf_have xrdb; then
dpi="$(xrdb -query 2>/dev/null | awk '/^Xft\.dpi:/ { print $2 }')"
[[ $dpi =~ ^[0-9]+$ ]] && RF_AUTO_SCALE=$(( (dpi * 100 + 48) / 96 ))
fi
[[ $RF_AUTO_SCALE -lt 100 ]] && RF_AUTO_SCALE=100
[[ $RF_AUTO_SCALE -gt 500 ]] && RF_AUTO_SCALE=500
return 0
}
# rf_share_dir
# The folder the apps may see, from the Share setting. Nothing for off.
rf_share_dir() {
local dir=''
case "$CFG_SHARE" in
home) dir="$HOME" ;;
documents) rf_have xdg-user-dir && dir="$(xdg-user-dir DOCUMENTS)" ;;
downloads) rf_have xdg-user-dir && dir="$(xdg-user-dir DOWNLOAD)" ;;
esac
# xdg-user-dir answers with the home folder for a folder that is not set
# up. Sharing all of it then would be a surprise.
[[ $CFG_SHARE != home && ${dir%/} == "${HOME%/}" ]] && return 1
[[ -n $dir && -d $dir ]] || return 1
printf '%s\n' "$dir"
}
# _rf_drive_name <dir>
# A name for a shared folder, as the app shows it: the folder's own name,
# without what Windows does not allow in one.
_rf_drive_name() {
local name="${1%/}"
name="${name##*/}"
name="${name//[\\\/:*?\"<>|,;]/_}"
printf '%s\n' "${name:-Linux}"
}
# _rf_drive_path <dir>
# The folder as FreeRDP can take it. /drive splits at commas, so a folder with
# one in its path is shared through a link that has none.
_rf_drive_path() {
local dir="$1" link
if [[ $dir != *,* ]]; then
printf '%s\n' "$dir"
return
fi
link="$RF_RUNDIR/links/$(_rf_drive_name "$dir")"
link="${link//,/_}"
mkdir -p "${link%/*}" && ln -sfn "$dir" "$link" && printf '%s\n' "$link"
}
# rf_ask_password <account>
# For when the keyring has none: in the terminal if there is one, else in a
# small window.
rf_ask_password() {
local prompt password
prompt="$(rf_msg "Password for %s:" "$1")"
if [[ -t 0 ]]; then
IFS= read -rsp " $prompt " password || return 1
printf '\n' >&2
elif rf_have kdialog; then
password="$(kdialog --title "$RF_PRETTY" --password "$prompt" 2>/dev/null)" || return 1
elif rf_have zenity; then
password="$(zenity --password --title "$prompt" 2>/dev/null)" || return 1
else
return 1
fi
[[ -n $password ]] || return 1
printf '%s' "$password"
}
# rf_launch_error <log>
# Why FreeRDP gave up, in words, from what it logged.
rf_launch_error() {
local log="$1" last
if grep -qE 'ERRCONNECT_(PASSWORD_EXPIRED|PASSWORD_MUST_CHANGE|PASSWORD_CERTAINLY_EXPIRED)' "$log"; then
rf_msg "Your password has expired. Change it on Windows or on the RD Web page, then sign in again in rdfeed."
elif grep -qE 'ERRCONNECT_ACCOUNT_LOCKED_OUT' "$log"; then
rf_msg "Your account is locked. Ask your IT department."
elif grep -qE 'ERRCONNECT_(LOGON_FAILURE|WRONG_PASSWORD|AUTHENTICATION_FAILED)|STATUS_LOGON_FAILURE' "$log"; then
rf_msg "The server did not accept the password. Sign in again in rdfeed."
elif grep -qE 'ERRCONNECT_(DNS_NAME_NOT_FOUND|DNS_ERROR|CONNECT_FAILED|CONNECT_TRANSPORT_FAILED)' "$log"; then
rf_msg "Could not reach the server. Check the internet connection."
else
last="$(grep '\[ERROR\]' "$log" | tail -n1 | sed 's/^.*\] - \[[^]]*\]: *//')"
rf_msg "FreeRDP stopped: %s" "${last:-?}"
fi
}
# rf_launch <id> <app> [file]
rf_launch() {
local id="$1" slug="$2" file="${3:-}" i title='' type='' icon rdp password log rcfile rc line kbd share
local drive='' remote='' temprdp='' dir name rel
local -a args env
rf_ws_load "$id"
rf_ws_apps_load "$id"
for i in "${!RF_APP_SLUGS[@]}"; do
if [[ ${RF_APP_SLUGS[i]} == "$slug" ]]; then
title="${RF_APP_TITLES[i]}" type="${RF_APP_TYPES[i]}"
fi
done
rdp="$RF_DATADIR/$id/rdp/$slug.rdp"
icon="$RF_DATADIR/$id/icons/$slug.png"
[[ -f $icon ]] || icon=rdfeed
if [[ -z $title || ! -f $rdp ]]; then
rf_bad "$(rf_msg "There is no app %s. See \`%s list\`." "$id/$slug" "$RF_NAME")"
return 1
fi
if [[ -n $file ]]; then
file="$(realpath -e -- "$file" 2>/dev/null)" && [[ -f $file ]] || {
rf_bad "$(rf_msg "File not found: %s" "${3:-}")"
rf_notify "$title" "$(rf_msg "File not found: %s" "${3:-}")" dialog-error
return 1
}
fi
if ! rf_freerdp; then
rf_bad "$(rf_msg "FreeRDP 3 is missing. Install it to start the apps (the package is called freerdp).")"
rf_notify "$title" "$(rf_msg "FreeRDP 3 is missing. Install it to start the apps (the package is called freerdp).")" dialog-error
return 1
fi
password="$(rf_secret_get "$id" "$WS_ACCOUNT")" || password=''
if [[ -z $password ]]; then
password="$(rf_ask_password "$WS_ACCOUNT")" || {
rf_notify "$title" "$(rf_msg "No password saved for %s. Sign in again in rdfeed." "$WS_NAME")" dialog-error
return 1
}
fi
rf_config_load
args=("$rdp" "/u:$WS_USER")
[[ -n $WS_DOMAIN && $WS_USER != *@* ]] && args+=("/d:$WS_DOMAIN")
args+=("/p:$password" /cert:tofu +clipboard)
kbd="$(rf_kbd_layout)" && args+=("/kbd:layout:$kbd")
[[ $CFG_SOUND == yes ]] && args+=(/sound)
if [[ $CFG_SCALE == auto ]]; then
rf_auto_scale
else
RF_AUTO_SCALE="$CFG_SCALE"
fi
[[ $RF_AUTO_SCALE -gt 100 ]] && args+=("/scale-desktop:$RF_AUTO_SCALE")
if share="$(rf_share_dir)"; then
name="$(_rf_drive_name "$share")"
args+=("/drive:$name,$(_rf_drive_path "$share")")
# A file in the shared folder needs no drive of its own.
if [[ -n $file && $file == "${share%/}"/* ]]; then
drive="$name"
rel="${file#"${share%/}"/}"
remote="\\\\tsclient\\$name\\${rel//\//\\}"
fi
fi
if [[ -n $file && -z $remote ]]; then
dir="${file%/*}"
drive="$(_rf_drive_name "$dir")"
args+=("/drive:$drive,$(_rf_drive_path "${dir:-/}")")
remote="\\\\tsclient\\$drive\\${file##*/}"
fi
if [[ -n $remote ]]; then
# The app gets the file as its command line, in a copy of the .rdp.
mkdir -p "$RF_RUNDIR"
temprdp="$(mktemp "$RF_RUNDIR/$id--$slug.XXXXXX.rdp")"
RF_CMDLINE="\"$remote\"" awk '
BEGIN { line = "remoteapplicationcmdline:s:" ENVIRON["RF_CMDLINE"] }
tolower($0) ~ /^remoteapplicationcmdline:s:/ { if (!done) print line "\r"; done = 1; next }
{ print }
END { if (!done) print line "\r" }
' "$rdp" > "$temprdp"
args[0]="$temprdp"
fi
[[ $type == Desktop ]] && args+=(/dynamic-resolution)
env=(WLOG_LEVEL="${WLOG_LEVEL:-INFO}" RDFEED_GW_WAIT="$CFG_MFAWAIT" RDFEED_GW_DELAY="$RF_GW_DELAY")
[[ $CFG_GRAPHICS == gfx ]] || env+=(RDFEED_NO_GFX=1)
[[ -f $RF_HOOK ]] && env+=(LD_PRELOAD="$RF_HOOK${LD_PRELOAD:+:$LD_PRELOAD}")
mkdir -p "$RF_CACHEDIR"
log="$RF_CACHEDIR/$id--$slug.log"
rcfile="$(mktemp "$RF_CACHEDIR/.exit.XXXXXX")"
: > "$log"
if [[ -n $file ]]; then
rf_notify "$(rf_msg "Opening %s in %s" "${file##*/}" "$title")" \
"$(rf_msg "If it does not open by itself, open it in the app from the drive %s." "$drive")" "$icon"
else
rf_notify "$(rf_msg "Starting %s" "$title")" \
"$(rf_msg "If your company uses MFA, confirm the sign-in in your authenticator app.")" "$icon"
fi
{
printf '%s\n' "${args[@]}" | env "${env[@]}" "$RF_FREERDP" /args-from:stdin 2>&1
printf '%s\n' "${PIPESTATUS[1]}" > "$rcfile"
} | while IFS= read -r line; do
printf '%s\n' "$line" >> "$log"
if [[ $line == "[rdfeed] gateway-delay"* ]]; then
rf_notify "$(rf_msg "A second sign-in is coming")" \
"$(rf_msg "Confirm the second prompt in your authenticator app too. It comes in about %d seconds." "$RF_GW_DELAY")" "$icon"
fi
done
rc="$(< "$rcfile")"
rm -f "$rcfile"
[[ -n $temprdp ]] && rm -f "$temprdp"
# Below 128 the session ended the normal way: closed, logged off, or taken
# over by the next app of the same workspace.
if [[ $rc =~ ^[0-9]+$ ]] && (( rc >= 128 )) && ! grep -q ERRCONNECT_CONNECT_CANCELLED "$log"; then
rf_notify "$(rf_msg "%s could not start" "$title")" "$(rf_launch_error "$log")" dialog-error
rf_bad "$(rf_launch_error "$log")"
rf_note "$(rf_msg "Log: %s" "$log")"
return 1
fi
return 0
}
+635
View File
@@ -0,0 +1,635 @@
# shellcheck shell=bash
#
# The interactive front end.
#
# This is the whole configuration interface. There are files behind it, but no
# part of the program ever asks anybody to open one: the workspaces, the apps,
# a settings list.
# Terminal mode.
#
# bash flips the terminal into non-canonical mode for each `read -sn1` and back
# out again in between. That gap matters: in canonical mode DEL is the ERASE
# character, so the line discipline eats it instead of delivering it, and a
# backspace typed while the interface was between reads simply vanishes.
# Holding non-canonical mode for the whole interface removes the gap.
RF_TERM_SAVED=''
rf_ui_term_raw() {
rf_have stty || return 0
[[ -t 0 ]] || return 0
[[ -n $RF_TERM_SAVED ]] && return 0
RF_TERM_SAVED="$(stty -g 2>/dev/null)" || { RF_TERM_SAVED=''; return 0; }
stty -icanon -echo min 1 time 0 2>/dev/null || true
}
rf_ui_term_restore() {
[[ -n $RF_TERM_SAVED ]] || return 0
stty "$RF_TERM_SAVED" 2>/dev/null || true
RF_TERM_SAVED=''
}
# Runs an action with the terminal handed back to normal line mode, so what it
# prompts for (a user name, a password) behaves the way a program expects.
rf_ui_cooked() {
rf_ui_term_restore
"$@"
local rc=$?
rf_ui_term_raw
return $rc
}
# rf_read_key
# One keypress, resolved to a symbolic name. Arrow keys arrive as ESC [ A, so
# the tail of the sequence is consumed here rather than being mistaken for
# three separate presses.
rf_read_key() {
local k rest
IFS= read -rsn1 k || return 1
case "$k" in
$'\e')
if IFS= read -rsn2 -t 0.05 rest; then
case "$rest" in
'[A') printf 'up\n' ;;
'[B') printf 'down\n' ;;
'[C') printf 'right\n' ;;
'[D') printf 'left\n' ;;
*) printf 'escape\n' ;;
esac
else
printf 'escape\n'
fi
;;
''|$'\r') printf 'enter\n' ;;
$'\x7f'|$'\b') printf 'backspace\n' ;;
' ') printf 'space\n' ;;
*) printf '%s\n' "$k" ;;
esac
}
# _rf_ui_take_notices
# The messages the last action left, as lines for under a frame, in
# RF_UI_NOTICE_TEXT. Each is shown once.
RF_UI_NOTICE_TEXT=''
_rf_ui_take_notices() {
local n
RF_UI_NOTICE_TEXT=''
(( ${#RF_UI_NOTICES[@]} )) || return 0
RF_UI_NOTICE_TEXT=$'\n'
for n in "${RF_UI_NOTICES[@]}"; do
RF_UI_NOTICE_TEXT+=" $n"$'\n'
done
RF_UI_NOTICES=()
}
# rf_ui_confirm <question>
rf_ui_confirm() {
local key hint yes
# TRANSLATORS: the letter after "[" is the key for yes. y works too.
hint="$(rf_msg "[y/N]")"
yes="${hint:1:1}"
printf '\n %s %s ' "$1" "$hint"
key="$(rf_read_key)" || return 1
printf '%s\n' "$key"
[[ ${key,,} == y || ${key,,} == "${yes,,}" ]]
}
# _rf_width <text>
# The columns the text takes, into RF_WIDTH. ${#s} counts characters, but
# Chinese, Japanese and Korean ones take two columns each.
RF_WIDTH=0
_rf_width() {
local wide="${1//[^ -〿぀-ヿ㐀-䶿一-鿿가-힯豈-﫿＀-⦆]/}"
RF_WIDTH=$(( ${#1} + ${#wide} ))
}
# _rf_pad <text> <columns>
# The text, padded with spaces to the columns, into RF_PADDED. printf's %-28s
# pads by bytes, so a label containing "ü" would come out one column short.
RF_PADDED=''
_rf_pad() {
local pad
_rf_width "$1"
pad=$(( $2 - RF_WIDTH ))
(( pad < 1 )) && pad=1
printf -v RF_PADDED '%s%*s' "$1" "$pad" ''
}
# _rf_wrap <width> <text>
# Word wrap, into RF_WRAP_LINES.
RF_WRAP_LINES=()
_rf_wrap() {
local width="$1" word line='' used=0 c i
local -a words
RF_WRAP_LINES=()
read -r -a words <<< "$2"
for word in "${words[@]}"; do
_rf_width "$word"
if (( used > 0 && used + 1 + RF_WIDTH > width )); then
RF_WRAP_LINES+=("$line")
line='' used=0
fi
(( used > 0 )) && line+=' ' used=$(( used + 1 ))
if (( used + RF_WIDTH <= width )); then
line+="$word" used=$(( used + RF_WIDTH ))
continue
fi
# Longer than a line: Chinese and Japanese have no spaces, so break
# between any two characters.
for (( i = 0; i < ${#word}; i++ )); do
c="${word:i:1}"
_rf_width "$c"
if (( used + RF_WIDTH > width )); then
RF_WRAP_LINES+=("$line")
line='' used=0
fi
line+="$c" used=$(( used + RF_WIDTH ))
done
done
[[ -n $line ]] && RF_WRAP_LINES+=("$line")
return 0
}
# _rf_apps_label <count>
_rf_apps_label() {
if [[ $1 == 1 ]]; then
rf_msg "1 app"
else
rf_msg "%d apps" "$1"
fi
}
# rf_start_detached <id> <app>
# Starts an app on its own, so it keeps running when the menu closes.
rf_start_detached() {
if rf_have setsid; then
setsid -f "$RF_SELF" run "$1/$2" > /dev/null 2>&1 < /dev/null
else
nohup "$RF_SELF" run "$1/$2" > /dev/null 2>&1 < /dev/null &
fi
}
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
# rf_ui_status
# The workspaces, for the head of the menu.
rf_ui_status() {
local id width=0 apps
rf_ws_ids
if (( ${#RF_WS_IDS[@]} == 0 )); then
printf ' %s\n' "$(rf_msg "No workspace yet. Add the one of your company with [1].")"
return 0
fi
for id in "${RF_WS_IDS[@]}"; do
rf_ws_load "$id"
_rf_width "$WS_NAME"
(( RF_WIDTH > width )) && width=$RF_WIDTH
done
for id in "${RF_WS_IDS[@]}"; do
rf_ws_load "$id"
rf_ws_apps_load "$id"
_rf_pad "$WS_NAME" $(( width + 3 ))
apps="$(_rf_apps_label "${#RF_APP_SLUGS[@]}")"
printf ' %s%-10s %s%s%s\n' "$RF_PADDED" "$apps" "$RF_C_DIM" "$WS_ACCOUNT" "$RF_C_RESET"
done
}
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
# Format: scope|Key|type|default|label-msgid|choices
# scope user (the settings file), entries (the settings file, and the app
# menu entries are written again), timer (the daily refresh, a
# systemd user timer), or group for a heading, with only the label
# after it
# type bool, or choice (steps through the choices)
RF_SETTINGS=(
"group|Apps"
"user|Share|choice|off|Share a folder|off,documents,downloads,home"
"entries|OpenWith|bool|yes|Open files with the apps"
"user|Scale|choice|auto|Size|auto,100,125,150,175,200"
"user|Sound|bool|yes|Sound"
"user|Graphics|choice|gfx|Graphics|gfx,classic"
"group|Signing in"
"user|MfaWait|choice|auto|Wait for the second MFA prompt|auto,always,never"
"timer|AutoRefresh|bool|yes|Refresh the apps every day"
)
# rf_value_label <Key> <value>
# How a setting's value reads in the menu.
rf_value_label() {
local dir
case "$1:$2" in
Share:off) rf_msg "off" ;;
Share:home) rf_msg "home folder" ;;
Share:documents|Share:downloads)
case "$2" in
documents) rf_msg "Documents" ;;
*) rf_msg "Downloads" ;;
esac
if dir="$(CFG_SHARE="$2" rf_share_dir)"; then
printf ' %s(%s)%s' "$RF_C_DIM" "${dir/#"$HOME"/\~}" "$RF_C_RESET"
else
printf ' %s(%s)%s' "$RF_C_YELLOW" "$(rf_msg "not found")" "$RF_C_RESET"
fi
;;
Scale:auto) rf_auto_scale; rf_msg "automatic (%d %%)" "$RF_AUTO_SCALE" ;;
Scale:*) printf '%s %%' "$2" ;;
Graphics:classic) rf_msg "classic" ;;
Graphics:*) rf_msg "GFX" ;;
MfaWait:always) rf_msg "always" ;;
MfaWait:never) rf_msg "never" ;;
MfaWait:*) rf_msg "automatic" ;;
*) printf '%s' "$2" ;;
esac
}
# rf_setting_help <Key> <value>
# What a setting does, shown under the list for the selected one.
rf_setting_help() {
case "$1:$2" in
Share:off) rf_msg "The apps only see the files on the server. Pick a folder to open and save files of this computer in them." ;;
Share:*) rf_msg "The apps can open and save files in this folder. It shows up as a drive in them." ;;
OpenWith:*) rf_msg "Offers the apps under \"Open with\" for their file types, for example Word for .docx. The folder of the file is shared with the app while it is open." ;;
Scale:*) rf_msg "How big the apps are drawn. Automatic follows the scale of your desktop, so they are sharp and the right size." ;;
Sound:*) rf_msg "Plays the sound of the apps here, for calls and videos." ;;
Graphics:classic) rf_msg "Paints every window right. But some servers end the session right after signing in, then go back to GFX." ;;
Graphics:*) rf_msg "Works with every server. FreeRDP may not repaint a window after it was minimized or resized." ;;
MfaWait:always) rf_msg "Always waits before the second sign-in. For when the second prompt does not come with automatic." ;;
MfaWait:never) rf_msg "Never waits. Right when your company has no MFA." ;;
MfaWait:*) rf_msg "Many servers with MFA need a second sign-in, and its prompt only comes after a short wait. Automatic waits when the first sign-in took a while." ;;
AutoRefresh:*)
if rf_timer_available; then
rf_msg "Loads the list of apps again once a day, so new apps show up by themselves."
else
rf_msg "Needs a systemd user session."
fi
;;
esac
}
# _rf_setting_value <scope> <Key> <default>
# The current value, in RF_SETTING_VALUE.
RF_SETTING_VALUE=''
_rf_setting_value() {
case "$1" in
user|entries) _rf_kv_lookup "$RF_CONFIG" "$2" "$3"; RF_SETTING_VALUE="$RF_KV_VALUE" ;;
timer) if rf_timer_enabled; then RF_SETTING_VALUE=yes; else RF_SETTING_VALUE=no; fi ;;
esac
}
# _rf_step <current> <step> <choice>...
# The choice <step> (1 or -1) away from the current one, round at the ends.
_rf_step() {
local current="$1" step="$2" i
shift 2
local -a all=("$@")
for i in "${!all[@]}"; do
if [[ ${all[i]} == "$current" ]]; then
printf '%s\n' "${all[(i + step + ${#all[@]}) % ${#all[@]}]}"
return
fi
done
printf '%s\n' "${all[0]}"
}
# _rf_setting_change <scope> <Key> <type> <current> <choices> <step>
_rf_setting_change() {
local scope="$1" key="$2" type="$3" current="$4" options="$5" step="$6" next
local -a choices
case "$type" in
bool) if rf_is_true "$current"; then next=no; else next=yes; fi ;;
choice)
IFS=',' read -r -a choices <<< "$options"
next="$(_rf_step "$current" "$step" "${choices[@]}")"
;;
esac
case "$scope" in
user)
rf_config_set "$key" "$next" || rf_bad "$(rf_msg "Could not save the setting.")"
;;
entries)
rf_config_set "$key" "$next" || rf_bad "$(rf_msg "Could not save the setting.")"
rf_ws_entries_all
;;
timer)
rf_config_set "$key" "$next"
rf_timer_set "$next" || rf_bad "$(rf_msg "Needs a systemd user session.")"
;;
esac
}
# rf_ui_settings
# A cursor list in groups, with what the selected setting does under it. The
# frame is assembled in memory and written once, and everything constant is
# resolved before the loop.
rf_ui_settings() {
local -a scopes=() keys=() types=() defaults=() labels=() widths=() choices=() values=() rows=()
local spec scope key type default label choice locale i j frame row pad dirty=1 cursor=0 shown
local width=0 wrap cols
locale="$(rf_ui_locale)"
for spec in "${RF_SETTINGS[@]}"; do
IFS='|' read -r scope key type default label choice <<< "$spec"
# A heading has its label where the key would be.
[[ $scope == group ]] && label="$key" key=''
scopes+=("$scope"); keys+=("$key"); types+=("$type"); defaults+=("$default")
choices+=("$choice")
rf_msg_into "$locale" "$label"
labels+=("$RF_MSG_RESULT")
_rf_width "$RF_MSG_RESULT"
widths+=("$RF_WIDTH")
if [[ $scope != group ]]; then
rows+=($(( ${#keys[@]} - 1 )))
(( RF_WIDTH > width )) && width=$RF_WIDTH
fi
done
local count=${#rows[@]}
cols="$(tput cols 2>/dev/null)" || cols=80
[[ $cols =~ ^[0-9]+$ ]] || cols=80
wrap=$(( cols - 4 ))
(( wrap > 72 )) && wrap=72
local rule
printf -v rule '%*s' "$wrap" ''
rule="${rule// /─}"
local title hint l_on l_off
rf_msg_into "$locale" "Settings"; title="$RF_MSG_RESULT"
rf_msg_into "$locale" "↑↓ select ←→ or Space: change q: back"; hint="$RF_MSG_RESULT"
rf_msg_into "$locale" "ON"; l_on="$RF_MSG_RESULT"
rf_msg_into "$locale" "OFF"; l_off="$RF_MSG_RESULT"
local clearseq
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
while true; do
if (( dirty )); then
RF_KV_CACHE=()
for i in "${rows[@]}"; do
_rf_setting_value "${scopes[i]}" "${keys[i]}" "${defaults[i]}"
values[i]="$RF_SETTING_VALUE"
done
dirty=0
fi
frame="$clearseq"$'\n'"${RF_C_BOLD}${RF_C_BLUE} ${title}${RF_C_RESET}"$'\n'
local selected=${rows[cursor]} marker before after
for i in "${!keys[@]}"; do
if [[ ${scopes[i]} == group ]]; then
frame+=$'\n'" ${RF_C_BOLD}${labels[i]}${RF_C_RESET}"$'\n'
continue
fi
if [[ ${types[i]} == bool ]]; then
if rf_is_true "${values[i]}"; then
shown="${RF_C_GREEN}${l_on}${RF_C_RESET}"
else
shown="${RF_C_DIM}${l_off}${RF_C_RESET}"
fi
else
shown="$(rf_value_label "${keys[i]}" "${values[i]}")"
fi
# Arrows on the selected choice: left and right step through it.
before=' ' after=''
if (( i == selected )) && [[ ${types[i]} != bool ]]; then
before="${RF_C_BLUE}◂${RF_C_RESET} " after=" ${RF_C_BLUE}▸${RF_C_RESET}"
fi
pad=$(( width + 2 - widths[i] ))
if (( i == selected )); then marker="${RF_C_BLUE}▸${RF_C_RESET} "; else marker=' '; fi
printf -v row ' %s%s%*s%s%s%s' "$marker" "${labels[i]}" "$pad" '' "$before" "$shown$RF_C_RESET" "$after"
frame+="$row"$'\n'
done
# What the selected setting does, in a box of fixed height so the
# screen does not jump while moving through the list.
_rf_wrap "$wrap" "$(rf_setting_help "${keys[selected]}" "${values[selected]}")"
frame+=$'\n'" ${RF_C_DIM}${rule}${RF_C_RESET}"$'\n'
for j in 0 1 2; do
frame+=" ${RF_WRAP_LINES[j]:-}"$'\n'
done
frame+=$'\n'" ${RF_C_DIM}${hint}${RF_C_RESET}"$'\n'
_rf_ui_take_notices
frame+="$RF_UI_NOTICE_TEXT"
printf '%s' "$frame"
key="$(rf_read_key)" || return 0
case "$key" in
up|k) cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) cursor=$(( (cursor + 1) % count )) ;;
space|right|l|left|h)
local step=1
[[ $key == left || $key == h ]] && step=-1
_rf_setting_change "${scopes[selected]}" "${keys[selected]}" "${types[selected]}" "${values[selected]}" "${choices[selected]}" "$step"
dirty=1
;;
q|Q|escape) return 0 ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# Workspaces
# ---------------------------------------------------------------------------
rf_ui_workspaces() {
local key frame row i cursor=0 count locale width
local -a ids=()
locale="$(rf_ui_locale)"
local title hint empty
rf_msg_into "$locale" "Workspaces"; title="$RF_MSG_RESULT"
# TRANSLATORS: keep the letters, they are the keys.
rf_msg_into "$locale" "↑↓ select r: refresh s: sign in again d: remove a: add q: back"; hint="$RF_MSG_RESULT"
rf_msg_into "$locale" "No workspace yet. Press a to add one."; empty="$RF_MSG_RESULT"
local clearseq
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
while true; do
rf_ws_ids
ids=("${RF_WS_IDS[@]}")
count=${#ids[@]}
(( cursor >= count )) && cursor=$(( count > 0 ? count - 1 : 0 ))
width=0
for i in "${!ids[@]}"; do
rf_ws_load "${ids[i]}"
_rf_width "$WS_NAME"
(( RF_WIDTH > width )) && width=$RF_WIDTH
done
frame="$clearseq"$'\n'"${RF_C_BOLD}${RF_C_BLUE} ${title}${RF_C_RESET}"$'\n\n'
(( count == 0 )) && frame+=" ${RF_C_DIM}${empty}${RF_C_RESET}"$'\n'
local marker apps when
for i in "${!ids[@]}"; do
rf_ws_load "${ids[i]}"
rf_ws_apps_load "${ids[i]}"
apps="$(_rf_apps_label "${#RF_APP_SLUGS[@]}")"
when="$(rf_msg "refreshed %s" "$(rf_time_ago "$WS_REFRESHED")")"
_rf_pad "$WS_NAME" $(( width + 3 ))
if (( i == cursor )); then marker="${RF_C_BLUE}▸${RF_C_RESET} "; else marker=' '; fi
printf -v row ' %s%s%-10s %s %s%s%s' "$marker" "$RF_PADDED" "$apps" "$WS_ACCOUNT" "$RF_C_DIM" "$when" "$RF_C_RESET"
frame+="$row"$'\n'
done
frame+=$'\n'" ${RF_C_DIM}${hint}${RF_C_RESET}"$'\n'
_rf_ui_take_notices
frame+="$RF_UI_NOTICE_TEXT"
printf '%s' "$frame"
key="$(rf_read_key)" || return 0
case "$key" in
up|k) (( count )) && cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) (( count )) && cursor=$(( (cursor + 1) % count )) ;;
r|R)
(( count )) || continue
printf '\n'
rf_ui_cooked rf_do_refresh "${ids[cursor]}"
;;
s|S)
(( count )) || continue
printf '\n'
rf_ui_cooked rf_do_signin_again "${ids[cursor]}"
;;
d|D)
(( count )) || continue
rf_ws_load "${ids[cursor]}"
if rf_ui_confirm "$(rf_msg "Remove \"%s\" and its apps?" "$WS_NAME")"; then
rf_do_remove "${ids[cursor]}" > /dev/null
fi
;;
a|A)
printf '\n'
rf_ui_cooked rf_do_add
;;
q|Q|escape) return 0 ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# Apps
# ---------------------------------------------------------------------------
rf_ui_apps() {
local key frame row i id cursor=0 count locale
local -a ws=() slugs=() titles=()
locale="$(rf_ui_locale)"
local title hint empty
rf_msg_into "$locale" "Start an app"; title="$RF_MSG_RESULT"
rf_msg_into "$locale" "↑↓ select Enter: start q: back"; hint="$RF_MSG_RESULT"
rf_msg_into "$locale" "No apps yet. Add a workspace first."; empty="$RF_MSG_RESULT"
rf_ws_ids
for id in "${RF_WS_IDS[@]}"; do
rf_ws_apps_load "$id"
for i in "${!RF_APP_SLUGS[@]}"; do
ws+=("$id") slugs+=("${RF_APP_SLUGS[i]}") titles+=("${RF_APP_TITLES[i]}")
done
done
count=${#slugs[@]}
local clearseq
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
while true; do
frame="$clearseq"$'\n'"${RF_C_BOLD}${RF_C_BLUE} ${title}${RF_C_RESET}"$'\n'
(( count == 0 )) && frame+=$'\n'" ${RF_C_DIM}${empty}${RF_C_RESET}"$'\n'
local marker last=''
for i in "${!slugs[@]}"; do
# A heading for each workspace.
if [[ ${ws[i]} != "$last" ]]; then
rf_ws_load "${ws[i]}"
frame+=$'\n'" ${RF_C_BOLD}${WS_NAME}${RF_C_RESET}"$'\n'
last="${ws[i]}"
fi
if (( i == cursor )); then marker="${RF_C_BLUE}▸${RF_C_RESET} "; else marker=' '; fi
printf -v row ' %s%s' "$marker" "${titles[i]}"
frame+="$row"$'\n'
done
frame+=$'\n'" ${RF_C_DIM}${hint}${RF_C_RESET}"$'\n'
_rf_ui_take_notices
frame+="$RF_UI_NOTICE_TEXT"
printf '%s' "$frame"
key="$(rf_read_key)" || return 0
case "$key" in
up|k) (( count )) && cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) (( count )) && cursor=$(( (cursor + 1) % count )) ;;
enter|space)
(( count )) || continue
rf_start_detached "${ws[cursor]}" "${slugs[cursor]}"
rf_ok "$(rf_msg "%s is starting. It opens in a window of its own." "${titles[cursor]}")" > /dev/null
;;
q|Q|escape) return 0 ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# The menu
# ---------------------------------------------------------------------------
rf_ui_menu() {
local choice
if [[ ! -t 0 ]]; then
rf_do_help
return 1
fi
rf_ui_term_raw
trap 'rf_ui_term_restore' EXIT INT TERM
while true; do
clear 2>/dev/null || true
rf_head " $RF_PRETTY"
rf_ui_status
printf '\n'
printf ' [1] %s\n' "$(rf_msg "Add a workspace")"
printf ' [2] %s\n' "$(rf_msg "Start an app")"
printf ' [3] %s\n' "$(rf_msg "Workspaces")"
printf ' [4] %s\n' "$(rf_msg "Settings")"
printf ' [q] %s\n' "$(rf_msg "Quit")"
_rf_ui_take_notices
printf '%s' "$RF_UI_NOTICE_TEXT"
printf '\n > '
choice="$(rf_read_key)" || {
printf '\n'; rf_ui_term_restore; trap - EXIT INT TERM; return 0
}
case "$choice" in
enter|space|up|down|left|right|escape) choice='' ;;
esac
printf '%s\n' "$choice"
case "$choice" in
1) printf '\n'; rf_ui_cooked rf_do_add ;;
2) rf_ui_apps ;;
3) rf_ui_workspaces ;;
4) rf_ui_settings ;;
q|Q) rf_ui_term_restore; trap - EXIT INT TERM; return 0 ;;
# Anything else (Enter, arrow keys, stray characters) just
# redraws. Escape is deliberately not a quit key, so a mistyped
# arrow key cannot close the menu.
*) ;;
esac
done
}
+389
View File
@@ -0,0 +1,389 @@
# shellcheck shell=bash
#
# Workspaces: the feeds rdfeed is subscribed to, and the app menu entries they
# turn into.
#
# A workspace has an id made from its server name. Its settings are in
# ~/.config/rdfeed/workspaces/<id>, its apps in ~/.local/share/rdfeed/<id>
# (apps.tsv, rdp/<app>.rdp, icons/<app>.png), its password in the keyring.
# Each app gets ~/.local/share/applications/rdfeed-<id>--<app>.desktop, and
# the workspace a folder of its own in the app menu. With "Open files with
# the apps" on, the entries also name the file types, so file managers offer
# the apps under "Open with".
# ---------------------------------------------------------------------------
# The keyring
# ---------------------------------------------------------------------------
rf_secret_get() {
rf_have secret-tool || return 1
secret-tool lookup service rdfeed workspace "$1" user "$2" 2>/dev/null
}
# rf_secret_set <id> <account> <label> <password>
# secret-tool keeps all of stdin, a trailing newline too, so printf and not
# a here-string. printf is a builtin: the password shows in no process list.
rf_secret_set() {
rf_have secret-tool || return 1
printf '%s' "$4" | secret-tool store --label="rdfeed: $3" service rdfeed workspace "$1" user "$2" \
> /dev/null 2>&1
}
rf_secret_clear() {
rf_have secret-tool || return 0
secret-tool clear service rdfeed workspace "$1" > /dev/null 2>&1 || true
}
# ---------------------------------------------------------------------------
# Finding the feed
# ---------------------------------------------------------------------------
# rf_discover <domain>
# Windows finds the feed for a work e-mail address in a TXT record of the
# domain, _msradc.<domain>. Whichever DNS tool is around asks for it.
rf_discover() {
local name="_msradc.$1" txt=''
rf_have resolvectl && txt="$(resolvectl query --legend=no -t TXT "$name" 2>/dev/null)"
[[ -z $txt ]] && rf_have dig && txt="$(dig +short TXT "$name" 2>/dev/null)"
[[ -z $txt ]] && rf_have host && txt="$(host -t TXT "$name" 2>/dev/null)"
[[ -z $txt ]] && rf_have drill && txt="$(drill TXT "$name" 2>/dev/null)"
[[ $txt =~ (https?://[^\"[:space:]]+) ]] || return 1
printf '%s\n' "${BASH_REMATCH[1]}"
}
# rf_feed_url <what was typed>
# A server name, an address or a work e-mail address, as the address of the
# feed, in RF_URL. 1: nothing typed, 2: no feed found for the e-mail address.
RF_URL=''
rf_feed_url() {
local in="$1" scheme rest host path
in="${in#"${in%%[![:space:]]*}"}"
in="${in%"${in##*[![:space:]]}"}"
RF_URL=''
[[ -n $in ]] || return 1
if [[ $in != */* && $in == *@* ]]; then
RF_URL="$(rf_discover "${in#*@}")" || return 2
return 0
fi
[[ $in == *://* ]] || in="https://$in"
scheme="${in%%://*}"
rest="${in#*://}"
host="${rest%%/*}"
path="${rest#"$host"}"
# The feed sits at the same place on every server. Whatever part of it
# was typed, the RD Web page included, leads there.
case "${path,,}" in
''|/|/rdweb|/rdweb/|/rdweb/feed|/rdweb/feed/|/rdweb/pages*) path=/RDWeb/Feed/webfeed.aspx ;;
*/webfeed) path+=.aspx ;;
esac
RF_URL="${scheme,,}://$host$path"
}
# rf_ws_id_for <url>
# The workspace that has this address, or a new id made from the server name.
rf_ws_id_for() {
local url="$1" id base n=2 host
rf_ws_ids
for id in "${RF_WS_IDS[@]}"; do
_rf_kv_lookup "$(rf_ws_file "$id")" Url
[[ ${RF_KV_VALUE,,} == "${url,,}" ]] && { printf '%s\n' "$id"; return; }
done
host="${url#*://}"
host="${host%%/*}"
host="${host%%:*}"
base="${host,,}"
base="${base//[^a-z0-9]/-}"
while [[ $base == *--* ]]; do base="${base//--/-}"; done
base="${base#-}"
base="${base%-}"
[[ -n $base ]] || base=workspace
id="$base"
while [[ -e $(rf_ws_file "$id") ]]; do
id="$base-$n"
n=$(( n + 1 ))
done
printf '%s\n' "$id"
}
# rf_split_user <typed>
# DOMAIN\name, name@domain or just a name, into RF_USER and RF_DOMAIN. The
# domain is "-" when the server's own should be taken, and empty for
# name@domain, which carries its domain.
rf_split_user() {
local in="$1"
if [[ $in == *\\* ]]; then
RF_DOMAIN="${in%%\\*}"
RF_USER="${in#*\\}"
elif [[ $in == *@* ]]; then
RF_DOMAIN=''
RF_USER="$in"
else
RF_DOMAIN='-'
RF_USER="$in"
fi
}
# ---------------------------------------------------------------------------
# Downloading
# ---------------------------------------------------------------------------
# rf_fetch <url> <user> <domain or -> <dir> <password> [quiet]
# Runs rdfeed-fetch into <dir>, printing each app as it arrives unless quiet.
# Leaves RF_FETCH_NAME, RF_FETCH_DOMAIN, RF_FETCH_COUNT and, when it failed,
# RF_FETCH_ERROR (the reason, already translated).
rf_fetch() {
local url="$1" user="$2" domain="$3" out="$4" password="$5" quiet="${6:-}"
local kind a b c rc=1 detail
local -a args=(--url "$url" --user "$user" --out "$out")
[[ $domain != - ]] && args+=(--domain "$domain")
RF_FETCH_NAME='' RF_FETCH_DOMAIN='' RF_FETCH_COUNT=0 RF_FETCH_ERROR=''
while IFS=$'\t' read -r kind a b c; do
case "$kind" in
workspace) RF_FETCH_NAME="$a" ;;
domain) RF_FETCH_DOMAIN="$a" ;;
app)
RF_FETCH_COUNT=$(( RF_FETCH_COUNT + 1 ))
[[ -z $quiet ]] && printf ' %s✔%s %s\n' "$RF_C_GREEN" "$RF_C_RESET" "$c"
;;
skip)
[[ -z $quiet ]] && printf ' %s✘%s %s %s(%s)%s\n' "$RF_C_RED" "$RF_C_RESET" "$a" "$RF_C_DIM" "$b" "$RF_C_RESET"
;;
exit) rc="$a" ;;
esac
done < <("$RF_FETCH" "${args[@]}" <<< "$password" 2> "$out/.error"; printf 'exit\t%s\n' "$?")
(( rc == 0 )) && { rm -f "$out/.error"; return 0; }
detail="$(tail -n1 "$out/.error" 2>/dev/null)"
detail="${detail#rdfeed-fetch: }"
case "$rc" in
10) RF_FETCH_ERROR="$(rf_msg "The server did not accept the user name or password.")" ;;
11) RF_FETCH_ERROR="$(rf_msg "Could not reach the server: %s" "$detail")" ;;
12) RF_FETCH_ERROR="$(rf_msg "There is no RD Web feed at %s." "$url")" ;;
*) RF_FETCH_ERROR="$(rf_msg "Could not load the apps: %s" "${detail:-$rc}")" ;;
esac
return "$rc"
}
# rf_stage_dir
# A fresh directory next to the workspaces, so moving it in place is a rename.
rf_stage_dir() {
mkdir -p "$RF_DATADIR" && mktemp -d "$RF_DATADIR/.new.XXXXXX"
}
# rf_ws_install <id> <stage dir>
rf_ws_install() {
local dir="$RF_DATADIR/$1"
rm -f "$2/.error"
rm -rf "$dir.old"
[[ -d $dir ]] && mv "$dir" "$dir.old"
mv "$2" "$dir" || { [[ -d $dir.old ]] && mv "$dir.old" "$dir"; return 1; }
rm -rf "$dir.old"
rf_ws_entries "$1"
}
# rf_ws_apps_load <id>
# The apps of a workspace in RF_APP_SLUGS, RF_APP_TYPES, RF_APP_TITLES and
# RF_APP_EXTS (the file types it opens, as "docx doc rtf").
RF_APP_SLUGS=() RF_APP_TYPES=() RF_APP_TITLES=() RF_APP_EXTS=()
rf_ws_apps_load() {
local slug type title exts file="$RF_DATADIR/$1/apps.tsv"
RF_APP_SLUGS=() RF_APP_TYPES=() RF_APP_TITLES=() RF_APP_EXTS=()
[[ -r $file ]] || return 0
while IFS=$'\t' read -r slug type title exts; do
[[ -n $slug ]] || continue
RF_APP_SLUGS+=("$slug") RF_APP_TYPES+=("$type") RF_APP_TITLES+=("$title") RF_APP_EXTS+=("$exts")
done < "$file"
}
# ---------------------------------------------------------------------------
# The app menu
# ---------------------------------------------------------------------------
_rf_desktop_value() {
local v="${1//\\/\\\\}"
v="${v//$'\n'/ }"
printf '%s' "${v//$'\r'/}"
}
# The command, as the Exec line of an entry wants it.
_rf_exec_path() {
if [[ $RF_SELF =~ ^[A-Za-z0-9_./+-]+$ ]]; then
printf '%s' "$RF_SELF"
else
local p="${RF_SELF//\\/\\\\\\\\}"
p="${p//\"/\\\\\"}"
p="${p//\$/\\\\\$}"
p="${p//\`/\\\\\`}"
printf '"%s"' "$p"
fi
}
# _rf_mime_types <ext>...
# The MIME types of the file extensions, as a MimeType= value, from the
# shared-mime-info database every desktop uses.
_rf_mime_types() {
local f
local -a globs=()
for f in "$RF_XDG_DATA/mime/globs2" /usr/local/share/mime/globs2 /usr/share/mime/globs2; do
[[ -r $f ]] && globs+=("$f")
done
(( ${#globs[@]} )) || return 0
awk -F: -v want=" $* " '
BEGIN { want = tolower(want) }
/^#/ { next }
{
glob = tolower($3)
if (glob !~ /^\*\.[a-z0-9_+-]+$/) next
if (index(want, " " substr(glob, 3) " ") && !seen[$2]++) out = out $2 ";"
}
END { printf "%s", out }
' "${globs[@]}"
}
# rf_menu_refresh
# KDE reads the app menu from a cache, which needs a nudge. GNOME and the rest
# watch the folders themselves.
rf_menu_refresh() {
if rf_have kbuildsycoca6; then
kbuildsycoca6 > /dev/null 2>&1 || true
elif rf_have kbuildsycoca5; then
kbuildsycoca5 > /dev/null 2>&1 || true
fi
rf_have update-desktop-database && update-desktop-database "$RF_APPDIR" > /dev/null 2>&1
return 0
}
# rf_ws_entries <id>
# An entry for each app, a folder for the workspace, and nothing left of apps
# the feed no longer has.
rf_ws_entries() {
local id="$1" i slug title icon comment file keywords mime openwith=no args
local -A keep=()
rf_ws_load "$id"
rf_ws_apps_load "$id"
mkdir -p "$RF_APPDIR" "$RF_MENUDIR" "$RF_DIRDIR" || return 1
_rf_kv_lookup "$RF_CONFIG" OpenWith yes
rf_is_true "$RF_KV_VALUE" && openwith=yes
keywords="$(_rf_desktop_value "$WS_NAME")"
keywords="${keywords//;/\\;}"
for i in "${!RF_APP_SLUGS[@]}"; do
slug="${RF_APP_SLUGS[i]}"
title="${RF_APP_TITLES[i]}"
icon="$RF_DATADIR/$id/icons/$slug.png"
[[ -f $icon ]] || icon=rdfeed
if [[ ${RF_APP_TYPES[i]} == Desktop ]]; then
comment="$(rf_msg "Remote desktop of %s" "$WS_NAME")"
else
comment="$(rf_msg "%s from %s" "$title" "$WS_NAME")"
fi
mime='' args=''
if [[ $openwith == yes && -n ${RF_APP_EXTS[i]} ]]; then
# shellcheck disable=SC2086 # one extension per argument
mime="$(_rf_mime_types ${RF_APP_EXTS[i]})"
[[ -n $mime ]] && args=' %f'
fi
file="$RF_APPDIR/rdfeed-$id--$slug.desktop"
keep[$file]=1
cat > "$file" <<- EOF
[Desktop Entry]
Type=Application
Name=$(_rf_desktop_value "$title")
GenericName=$(_rf_desktop_value "$WS_NAME")
Comment=$(_rf_desktop_value "$comment")
Exec=$(_rf_exec_path) run $id/$slug$args
Icon=$icon
Terminal=false
StartupNotify=false
Categories=X-rdfeed-$id;
Keywords=RemoteApp;RDP;$keywords;
EOF
[[ -n $mime ]] && printf 'MimeType=%s\n' "$mime" >> "$file"
done
for file in "$RF_APPDIR"/rdfeed-"$id"--*.desktop; do
[[ -e $file && -z ${keep[$file]:-} ]] && rm -f "$file"
done
cat > "$RF_MENUDIR/rdfeed-$id.menu" <<- EOF
<!DOCTYPE Menu PUBLIC "-//freedesktop//DTD Menu 1.0//EN"
"http://www.freedesktop.org/standards/menu-spec/menu-1.0.dtd">
<!-- Written by rdfeed: the folder of the workspace $id. -->
<Menu>
<Name>Applications</Name>
<Menu>
<Name>rdfeed-$id</Name>
<Directory>rdfeed-$id.directory</Directory>
<Include>
<Category>X-rdfeed-$id</Category>
</Include>
</Menu>
</Menu>
EOF
cat > "$RF_DIRDIR/rdfeed-$id.directory" <<- EOF
[Desktop Entry]
Type=Directory
Name=$(_rf_desktop_value "$WS_NAME")
Icon=rdfeed
EOF
rf_menu_refresh
}
# rf_ws_entries_all
# The entries of every workspace again, after a setting that shows in them.
rf_ws_entries_all() {
local id
rf_ws_ids
for id in "${RF_WS_IDS[@]}"; do
rf_ws_entries "$id"
done
}
# rf_ws_purge <id>
# Everything of a workspace: entries, folder, apps, settings, password, logs.
rf_ws_purge() {
local id="$1" file
for file in "$RF_APPDIR"/rdfeed-"$id"--*.desktop; do
[[ -e $file ]] && rm -f "$file"
done
rm -f "$RF_MENUDIR/rdfeed-$id.menu" "$RF_DIRDIR/rdfeed-$id.directory"
rm -rf "${RF_DATADIR:?}/$id"
rm -f "$(rf_ws_file "$id")"
for file in "$RF_CACHEDIR/$id"--*.log; do
[[ -e $file ]] && rm -f "$file"
done
rf_secret_clear "$id"
rf_menu_refresh
}
# ---------------------------------------------------------------------------
# Refreshing every day
# ---------------------------------------------------------------------------
rf_timer_available() {
rf_have systemctl || return 1
systemctl --user cat "$RF_UNIT_TIMER" > /dev/null 2>&1
}
rf_timer_enabled() {
rf_have systemctl || return 1
[[ $(systemctl --user is-enabled "$RF_UNIT_TIMER" 2>/dev/null) == enabled ]]
}
# rf_timer_set yes|no
rf_timer_set() {
rf_timer_available || return 1
if [[ $1 == yes ]]; then
systemctl --user enable --now "$RF_UNIT_TIMER" > /dev/null 2>&1
else
systemctl --user disable --now "$RF_UNIT_TIMER" > /dev/null 2>&1
fi
}
Executable
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env bash
#
# rdfeed: the RemoteApps of your company in the Linux app menu
#
# Companies publish their Windows apps (Word, Excel, their own tools) as an RD
# Web Access feed. Windows subscribes to it under "RemoteApp and Desktop
# Connections" and puts the apps in the start menu. rdfeed does the same on
# Linux: each app gets an entry in the app menu and opens as a window of its
# own, through FreeRDP.
#
# This is the front end: the menu and the commands. Signing in to the feed and
# downloading the apps is rdfeed-fetch's job, and rdfeed-hook.so goes into
# FreeRDP to work around two of its problems (see launch.sh).
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
set -uo pipefail
RF_SELF="$(readlink -f "${BASH_SOURCE[0]}")"
RF_LIBDIR="${RF_LIBDIR:-@LIBDIR@}"
for _mod in common config workspace launch menu; do
# shellcheck source=/dev/null
if ! source "$RF_LIBDIR/$_mod.sh"; then
printf 'rdfeed: cannot load %s/%s.sh\n' "$RF_LIBDIR" "$_mod" >&2
exit 14
fi
done
unset _mod
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
# rf_prompt <question> [default]
# A line typed in, in RF_ANSWER. Without a terminal it is read from stdin, so
# scripts can pipe the answers in.
RF_ANSWER=''
rf_prompt() {
RF_ANSWER=''
if [[ -t 0 ]]; then
IFS= read -rep " $1 " -i "${2:-}" RF_ANSWER || return 1
else
IFS= read -r RF_ANSWER || return 1
fi
return 0
}
# rf_prompt_password <question>
rf_prompt_password() {
RF_ANSWER=''
if [[ -t 0 ]]; then
IFS= read -rsp " $1 " RF_ANSWER || return 1
printf '\n'
else
IFS= read -r RF_ANSWER || return 1
fi
[[ -n $RF_ANSWER ]]
}
# rf_signin <id or ""> <url> <user as typed> <password>
# Signs in, downloads the apps and puts them in the app menu. With an empty
# id a new workspace is made, or the one that already has this address.
rf_signin() {
local id="$1" url="$2" typed="$3" password="$4" stage old_account first=0 domain
[[ -n $id ]] || id="$(rf_ws_id_for "$url")"
rf_ws_ids
(( ${#RF_WS_IDS[@]} == 0 )) && first=1
old_account=''
if [[ -e $(rf_ws_file "$id") ]]; then
rf_ws_load "$id"
old_account="$WS_ACCOUNT"
fi
rf_split_user "$typed"
stage="$(rf_stage_dir)" || { rf_bad "$(rf_msg "Could not write to %s." "$RF_DATADIR")"; return 1; }
local host="${url#*://}"
rf_say " $(rf_msg "Signing in to %s…" "${host%%/*}")"
if ! rf_fetch "$url" "$RF_USER" "$RF_DOMAIN" "$stage" "$password"; then
rm -rf "$stage"
rf_bad "$RF_FETCH_ERROR"
return 1
fi
domain="$RF_DOMAIN"
[[ $domain == - ]] && domain="$RF_FETCH_DOMAIN"
rf_ws_set "$id" Url "$url"
rf_ws_set "$id" Name "$RF_FETCH_NAME"
rf_ws_set "$id" User "$RF_USER"
rf_ws_set "$id" Domain "$domain"
rf_ws_set "$id" Refreshed "$(date +%s)"
rf_ws_load "$id"
[[ -n $old_account && $old_account != "$WS_ACCOUNT" ]] && rf_secret_clear "$id"
if ! rf_secret_set "$id" "$WS_ACCOUNT" "$WS_NAME" "$password"; then
rf_note "$(rf_msg "The password could not be saved in the keyring, so each app asks for it.")"
fi
rf_ws_install "$id" "$stage" || { rf_bad "$(rf_msg "Could not write to %s." "$RF_DATADIR")"; return 1; }
# New apps should show up by themselves, as on Windows.
if (( first )) && [[ $(rf_config_get AutoRefresh yes) == yes ]]; then
rf_timer_set yes || true
fi
if (( RF_FETCH_COUNT == 1 )); then
rf_ok "$(rf_msg "1 app from %s is now in your app menu." "$WS_NAME")"
else
rf_ok "$(rf_msg "%d apps from %s are now in your app menu." "$RF_FETCH_COUNT" "$WS_NAME")"
fi
}
rf_do_add() {
local input='' user='' url rc
while (( $# )); do
case "$1" in
--user) user="${2:-}"; shift ;;
*) input="$1" ;;
esac
shift
done
if [[ -z $input ]]; then
rf_say " $(rf_msg "Your IT department has the address. A work e-mail address often works too.")"
rf_prompt "$(rf_msg "Feed address, server or e-mail:")" || return 1
input="$RF_ANSWER"
fi
rf_feed_url "$input"
rc=$?
case "$rc" in
1) return 1 ;;
2) rf_bad "$(rf_msg "No feed is registered for %s. Ask your IT department for the address." "$input")"; return 1 ;;
esac
url="$RF_URL"
if [[ -z $user ]]; then
rf_prompt "$(rf_msg "User name (DOMAIN\\name or name@domain):")" || return 1
user="$RF_ANSWER"
fi
[[ -n $user ]] || return 1
rf_prompt_password "$(rf_msg "Password:")" || return 1
rf_signin '' "$url" "$user" "$RF_ANSWER"
}
# rf_do_signin_again <id>
# Another password or another user for a workspace.
rf_do_signin_again() {
local id="$1"
rf_ws_load "$id"
rf_prompt "$(rf_msg "User name (DOMAIN\\name or name@domain):")" "$WS_ACCOUNT" || return 1
[[ -n $RF_ANSWER ]] || return 1
local user="$RF_ANSWER"
rf_prompt_password "$(rf_msg "Password:")" || return 1
rf_signin "$id" "$WS_URL" "$user" "$RF_ANSWER"
}
# rf_do_refresh [id] [--quiet]
# The quiet one is the daily timer's: no questions, and a notification only
# when somebody has to do something.
rf_do_refresh() {
local quiet='' only='' id password stage rc failed=0
while (( $# )); do
case "$1" in
--quiet) quiet=1 ;;
*) only="$1" ;;
esac
shift
done
rf_ws_ids
if (( ${#RF_WS_IDS[@]} == 0 )); then
[[ -z $quiet ]] && rf_note "$(rf_msg "No workspace yet. Add one with \`%s add\`." "$RF_NAME")"
return 0
fi
for id in "${RF_WS_IDS[@]}"; do
[[ -n $only && $id != "$only" ]] && continue
rf_ws_load "$id"
password="$(rf_secret_get "$id" "$WS_ACCOUNT")" || password=''
if [[ -z $password ]]; then
if [[ -n $quiet || ! -t 0 ]]; then
[[ -n $quiet ]] && rf_notify "$WS_NAME" "$(rf_msg "No password saved for %s. Sign in again in rdfeed." "$WS_NAME")" dialog-warning
failed=1
continue
fi
rf_prompt_password "$(rf_msg "Password for %s:" "$WS_ACCOUNT")" || { failed=1; continue; }
password="$RF_ANSWER"
fi
[[ -z $quiet ]] && rf_say " $(rf_msg "Refreshing %s…" "$WS_NAME")"
stage="$(rf_stage_dir)" || return 1
rf_fetch "$WS_URL" "$WS_USER" "${WS_DOMAIN:-}" "$stage" "$password" "$quiet"
rc=$?
if (( rc != 0 )); then
rm -rf "$stage"
failed=1
if [[ -n $quiet ]]; then
# A server that is not reachable now will be tomorrow. A
# password that no longer works needs the user.
(( rc == 10 )) && rf_notify "$WS_NAME" "$(rf_msg "The password no longer works. Sign in again in rdfeed.")" dialog-warning
else
rf_bad "$RF_FETCH_ERROR"
fi
continue
fi
[[ -n $RF_FETCH_NAME ]] && rf_ws_set "$id" Name "$RF_FETCH_NAME"
rf_ws_set "$id" Refreshed "$(date +%s)"
rf_ws_install "$id" "$stage" || { failed=1; continue; }
[[ -z $quiet ]] && rf_ok "$(rf_msg "%s: %d apps" "$WS_NAME" "$RF_FETCH_COUNT")"
done
return "$failed"
}
rf_do_remove() {
local id="${1:-}"
if [[ -z $id || ! -e $(rf_ws_file "$id") ]]; then
rf_bad "$(rf_msg "Which workspace? See \`%s list\` for the names." "$RF_NAME")"
return 1
fi
rf_ws_load "$id"
rf_ws_purge "$id"
rf_ws_ids
(( ${#RF_WS_IDS[@]} == 0 )) && { rf_timer_set no || true; }
rf_ok "$(rf_msg "%s and its apps are removed." "$WS_NAME")"
}
rf_do_list() {
local id i
rf_ws_ids
if (( ${#RF_WS_IDS[@]} == 0 )); then
rf_note "$(rf_msg "No workspace yet. Add one with \`%s add\`." "$RF_NAME")"
return 0
fi
for id in "${RF_WS_IDS[@]}"; do
rf_ws_load "$id"
rf_ws_apps_load "$id"
printf '%s%s%s %s(%s, %s)%s\n' "$RF_C_BOLD" "$WS_NAME" "$RF_C_RESET" "$RF_C_DIM" "$id" "$WS_ACCOUNT" "$RF_C_RESET"
for i in "${!RF_APP_SLUGS[@]}"; do
printf ' %-36s %s\n' "$id/${RF_APP_SLUGS[i]}" "${RF_APP_TITLES[i]}"
done
done
}
# rf_do_run <app or workspace/app> [file]
rf_do_run() {
local spec="${1:-}" file="${2:-}" id slug found=() i
if [[ -z $spec ]]; then
rf_bad "$(rf_msg "Which app? See \`%s list\`." "$RF_NAME")"
return 1
fi
if [[ $spec == */* ]]; then
rf_launch "${spec%%/*}" "${spec#*/}" "$file"
return
fi
rf_ws_ids
for id in "${RF_WS_IDS[@]}"; do
rf_ws_apps_load "$id"
for i in "${!RF_APP_SLUGS[@]}"; do
[[ ${RF_APP_SLUGS[i]} == "$spec" ]] && found+=("$id")
done
done
case "${#found[@]}" in
0) rf_bad "$(rf_msg "There is no app %s. See \`%s list\`." "$spec" "$RF_NAME")"; return 1 ;;
1) slug="$spec"; rf_launch "${found[0]}" "$slug" "$file" ;;
*) rf_bad "$(rf_msg "Several workspaces have %s. Write it as WORKSPACE/APP, see \`%s list\`." "$spec" "$RF_NAME")"; return 1 ;;
esac
}
rf_do_help() {
cat <<- EOF
$RF_PRETTY $RF_VERSION
$(rf_msg "Usage: rdfeed [command]")
$(rf_msg "Commands:")
add [ADDRESS] $(rf_msg "Add the workspace of your company")
refresh [NAME] $(rf_msg "Load the apps again")
list $(rf_msg "List the workspaces and their apps")
run APP [FILE] $(rf_msg "Start an app, or open a file with it")
remove NAME $(rf_msg "Remove a workspace and its apps")
-h, --help $(rf_msg "Show this help")
-V, --version $(rf_msg "Show the version")
$(rf_msg "Without a command an interactive menu is shown.")
EOF
}
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
main() {
local cmd="${1:-}"
[[ $# -gt 0 ]] && shift
case "$cmd" in
add|subscribe) rf_do_add "$@" ;;
refresh|sync) rf_do_refresh "$@" ;;
list|ls) rf_do_list ;;
run|start) rf_do_run "$@" ;;
remove|delete) rf_do_remove "$@" ;;
-h|--help|help) rf_do_help ;;
-V|--version) printf '%s %s\n' "$RF_NAME" "$RF_VERSION" ;;
'') rf_ui_menu ;;
*)
rf_bad "$(rf_msg "Unknown command: %s" "$cmd")"
printf '\n'
rf_do_help
exit 1
;;
esac
}
main "$@"