This commit is contained in:
commit
3e98dd1e7e
48 files changed
+13025
No files matched your search
Executable
+617
@@ -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:]))
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 "$@"
|
||||
Reference in new issue
Block a user