Compare commits
3 Commits
10683ebc73
...
b96b018f70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b96b018f70 | ||
|
|
65a65939a7 | ||
|
|
25723b7f07 |
@@ -9,6 +9,8 @@ final class SparkleUpdater: NSObject, ObservableObject {
|
|||||||
}()
|
}()
|
||||||
private let logger = Logger(subsystem: "net.24unix.iKeyMon", category: "Sparkle")
|
private let logger = Logger(subsystem: "net.24unix.iKeyMon", category: "Sparkle")
|
||||||
private let verboseLogging: Bool
|
private let verboseLogging: Bool
|
||||||
|
@Published var logMessages: [String] = []
|
||||||
|
@Published var showLogs: Bool = false
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
self.verboseLogging = ProcessInfo.processInfo.environment["SPARKLE_VERBOSE_LOGGING"] == "1"
|
self.verboseLogging = ProcessInfo.processInfo.environment["SPARKLE_VERBOSE_LOGGING"] == "1"
|
||||||
@@ -34,6 +36,7 @@ final class SparkleUpdater: NSObject, ObservableObject {
|
|||||||
|
|
||||||
private func log(_ message: String) {
|
private func log(_ message: String) {
|
||||||
logger.log("\(message, privacy: .public)")
|
logger.log("\(message, privacy: .public)")
|
||||||
|
addLogMessage("[INFO] \(message)")
|
||||||
if verboseLogging {
|
if verboseLogging {
|
||||||
print("[Sparkle] \(message)")
|
print("[Sparkle] \(message)")
|
||||||
}
|
}
|
||||||
@@ -41,11 +44,21 @@ final class SparkleUpdater: NSObject, ObservableObject {
|
|||||||
|
|
||||||
private func logError(_ message: String) {
|
private func logError(_ message: String) {
|
||||||
logger.error("\(message, privacy: .public)")
|
logger.error("\(message, privacy: .public)")
|
||||||
|
addLogMessage("[ERROR] \(message)")
|
||||||
if verboseLogging {
|
if verboseLogging {
|
||||||
fputs("[Sparkle][error] \(message)\n", stderr)
|
fputs("[Sparkle][error] \(message)\n", stderr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func addLogMessage(_ message: String) {
|
||||||
|
let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
|
||||||
|
let timestampedMessage = "[\(timestamp)] \(message)"
|
||||||
|
logMessages.append(timestampedMessage)
|
||||||
|
if logMessages.count > 100 {
|
||||||
|
logMessages.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func describe(update item: SUAppcastItem) -> String {
|
private func describe(update item: SUAppcastItem) -> String {
|
||||||
let short = item.displayVersionString
|
let short = item.displayVersionString
|
||||||
let build = item.versionString
|
let build = item.versionString
|
||||||
@@ -53,41 +66,58 @@ final class SparkleUpdater: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
extension SparkleUpdater: SPUUpdaterDelegate {
|
extension SparkleUpdater: SPUUpdaterDelegate {
|
||||||
func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) {
|
nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) {
|
||||||
|
Task { @MainActor in
|
||||||
log("Loaded Sparkle appcast containing \(appcast.items.count) item(s).")
|
log("Loaded Sparkle appcast containing \(appcast.items.count) item(s).")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
|
nonisolated func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
|
||||||
|
Task { @MainActor in
|
||||||
log("Found valid update \(describe(update: item))")
|
log("Found valid update \(describe(update: item))")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
|
nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
|
||||||
|
Task { @MainActor in
|
||||||
log("No updates available.")
|
log("No updates available.")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func updater(_ updater: SPUUpdater, willDownloadUpdate item: SUAppcastItem, with request: NSMutableURLRequest) {
|
nonisolated func updater(_ updater: SPUUpdater, willDownloadUpdate item: SUAppcastItem, with request: NSMutableURLRequest) {
|
||||||
|
Task { @MainActor in
|
||||||
log("Downloading \(describe(update: item)) from \(request.url?.absoluteString ?? "unknown URL")")
|
log("Downloading \(describe(update: item)) from \(request.url?.absoluteString ?? "unknown URL")")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
|
nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
|
||||||
|
Task { @MainActor in
|
||||||
log("Finished downloading \(describe(update: item))")
|
log("Finished downloading \(describe(update: item))")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) {
|
nonisolated func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) {
|
||||||
|
Task { @MainActor in
|
||||||
logError("Failed to download \(describe(update: item)): \(error.localizedDescription)")
|
logError("Failed to download \(describe(update: item)): \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func userDidCancelDownload(_ updater: SPUUpdater) {
|
nonisolated func userDidCancelDownload(_ updater: SPUUpdater) {
|
||||||
|
Task { @MainActor in
|
||||||
log("User cancelled Sparkle download.")
|
log("User cancelled Sparkle download.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) {
|
|
||||||
log("Will install update \(describe(update: item))")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
|
nonisolated func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) {
|
||||||
|
Task { @MainActor in
|
||||||
|
log("Will install update \(describe(update: item))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
|
||||||
|
Task { @MainActor in
|
||||||
logError("Sparkle aborted: \(error.localizedDescription)")
|
logError("Sparkle aborted: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -257,11 +257,64 @@ private struct UpdatesPreferencesView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
Button(action: { sparkleUpdater.showLogs.toggle() }) {
|
||||||
|
Label(sparkleUpdater.showLogs ? "Hide Logs" : "Show Logs", systemImage: "terminal.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sparkleUpdater.showLogs {
|
||||||
|
logsView
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var logsView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Update Logs")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if sparkleUpdater.logMessages.isEmpty {
|
||||||
|
Text("No logs yet. Check for updates to see activity.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.italic()
|
||||||
|
} else {
|
||||||
|
ForEach(sparkleUpdater.logMessages, id: \.self) { message in
|
||||||
|
Text(message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(3)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.frame(height: 200)
|
||||||
|
.background(Color(nsColor: .controlBackgroundColor))
|
||||||
|
.cornerRadius(6)
|
||||||
|
.border(.separator, width: 1)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
sparkleUpdater.logMessages.removeAll()
|
||||||
|
}) {
|
||||||
|
Label("Clear Logs", systemImage: "trash")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.disabled(sparkleUpdater.logMessages.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct NotificationsPreferencesView: View {
|
private struct NotificationsPreferencesView: View {
|
||||||
|
|||||||
16
Sparkle/appcast.xml
vendored
16
Sparkle/appcast.xml
vendored
@@ -2,6 +2,14 @@
|
|||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>iKeyMon</title>
|
<title>iKeyMon</title>
|
||||||
|
<item>
|
||||||
|
<title>26.0.41</title>
|
||||||
|
<pubDate>Tue, 30 Dec 2025 13:18:56 +0100</pubDate>
|
||||||
|
<sparkle:version>86</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>26.0.41</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
|
||||||
|
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.41/iKeyMon-26.0.41.zip" length="4847296" type="application/octet-stream" sparkle:edSignature="8ITx6pM0LDVh0IpAz7eej7+eomnLTvRbchNaxSbdfQT/A16Bxg6F+FgtRDJkV5khJTrunpEPal9x3OUi7FO4Bg=="/>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>26.0.40</title>
|
<title>26.0.40</title>
|
||||||
<pubDate>Tue, 30 Dec 2025 13:01:18 +0100</pubDate>
|
<pubDate>Tue, 30 Dec 2025 13:01:18 +0100</pubDate>
|
||||||
@@ -18,13 +26,5 @@
|
|||||||
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
|
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
|
||||||
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.39/iKeyMon-26.0.39.zip" length="4829477" type="application/octet-stream" sparkle:edSignature="/J0LTgTj5uFa30okaLC7l6+wFUQxu8/E18y8vu3wqCXnM8tCG6TanZaGY69UWTUzaO9858oORy2yY6/MYtERBw=="/>
|
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.39/iKeyMon-26.0.39.zip" length="4829477" type="application/octet-stream" sparkle:edSignature="/J0LTgTj5uFa30okaLC7l6+wFUQxu8/E18y8vu3wqCXnM8tCG6TanZaGY69UWTUzaO9858oORy2yY6/MYtERBw=="/>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
|
||||||
<title>26.0.38</title>
|
|
||||||
<pubDate>Tue, 30 Dec 2025 12:37:13 +0100</pubDate>
|
|
||||||
<sparkle:version>80</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>26.0.38</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.38/iKeyMon-26.0.38.zip" length="4829624" type="application/octet-stream" sparkle:edSignature="Pydppvzp9xiEXWOTObEyx6f7m3qZyFZYHCovclUk8xEbXNUAas70296TrjKvAhJvu9dLkObEyO39A/B2ZLQTAw=="/>
|
|
||||||
</item>
|
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements;
|
CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 83;
|
CURRENT_PROJECT_VERSION = 86;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = Q5486ZVAFT;
|
DEVELOPMENT_TEAM = Q5486ZVAFT;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 26.0.40;
|
MARKETING_VERSION = 26.0.41;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon;
|
PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -353,7 +353,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements;
|
CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 83;
|
CURRENT_PROJECT_VERSION = 86;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = Q5486ZVAFT;
|
DEVELOPMENT_TEAM = Q5486ZVAFT;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -368,7 +368,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 26.0.40;
|
MARKETING_VERSION = 26.0.41;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon;
|
PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Generate a local appcast that reuses the latest Sparkle entry but bumps the
|
|
||||||
# sparkle:version so Sparkle will reinstall the same build. Useful for testing
|
|
||||||
# updates without cutting a new release.
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
INPUT="${1:-$ROOT_DIR/Sparkle/appcast.xml}"
|
|
||||||
OUTPUT="${2:-$ROOT_DIR/Sparkle/appcast-local.xml}"
|
|
||||||
|
|
||||||
python3 - "$INPUT" "$OUTPUT" <<'PY'
|
|
||||||
import sys, xml.etree.ElementTree as ET, datetime, email.utils
|
|
||||||
|
|
||||||
if len(sys.argv) != 3:
|
|
||||||
sys.exit("Usage: make_local_appcast.sh [input] [output]")
|
|
||||||
|
|
||||||
src, dst = sys.argv[1], sys.argv[2]
|
|
||||||
tree = ET.parse(src)
|
|
||||||
root = tree.getroot()
|
|
||||||
channel = root.find("channel")
|
|
||||||
items = channel.findall("item") if channel is not None else []
|
|
||||||
if not items:
|
|
||||||
sys.exit("No items found in appcast")
|
|
||||||
|
|
||||||
latest = items[0]
|
|
||||||
enc = latest.find("enclosure")
|
|
||||||
if enc is None:
|
|
||||||
sys.exit("Latest item missing enclosure")
|
|
||||||
|
|
||||||
sparkle_ns = "{http://www.andymatuschak.org/xml-namespaces/sparkle}"
|
|
||||||
|
|
||||||
def get_text(tag):
|
|
||||||
el = latest.find(tag)
|
|
||||||
return el.text if el is not None else ""
|
|
||||||
|
|
||||||
short_version = get_text(f"{sparkle_ns}shortVersionString")
|
|
||||||
version_el = latest.find(f"{sparkle_ns}version")
|
|
||||||
try:
|
|
||||||
next_build = str(int(version_el.text) + 1000 if version_el is not None else 9999)
|
|
||||||
except ValueError:
|
|
||||||
next_build = "9999"
|
|
||||||
|
|
||||||
now_rfc822 = email.utils.format_datetime(datetime.datetime.now(datetime.timezone.utc))
|
|
||||||
|
|
||||||
new_item = ET.Element("item")
|
|
||||||
ET.SubElement(new_item, "title").text = f"{short_version} (reinstall)"
|
|
||||||
ET.SubElement(new_item, "pubDate").text = now_rfc822
|
|
||||||
sv = ET.SubElement(new_item, f"{sparkle_ns}shortVersionString")
|
|
||||||
sv.text = short_version
|
|
||||||
bv = ET.SubElement(new_item, f"{sparkle_ns}version")
|
|
||||||
bv.text = next_build
|
|
||||||
min_os = latest.find(f"{sparkle_ns}minimumSystemVersion")
|
|
||||||
if min_os is not None and min_os.text:
|
|
||||||
ET.SubElement(new_item, f"{sparkle_ns}minimumSystemVersion").text = min_os.text
|
|
||||||
new_enc = ET.SubElement(new_item, "enclosure")
|
|
||||||
for attr in ("url", "length", "type", f"{sparkle_ns}edSignature"):
|
|
||||||
if attr in enc.attrib:
|
|
||||||
new_enc.set(attr, enc.attrib[attr])
|
|
||||||
|
|
||||||
new_channel = ET.Element("channel")
|
|
||||||
title = channel.find("title")
|
|
||||||
ET.SubElement(new_channel, "title").text = title.text if title is not None else "Local"
|
|
||||||
new_channel.append(new_item)
|
|
||||||
|
|
||||||
new_root = ET.Element("rss", {
|
|
||||||
"version": "2.0",
|
|
||||||
"xmlns:sparkle": "http://www.andymatuschak.org/xml-namespaces/sparkle"
|
|
||||||
})
|
|
||||||
new_root.append(new_channel)
|
|
||||||
|
|
||||||
ET.indent(new_root, space=" ")
|
|
||||||
ET.ElementTree(new_root).write(dst, encoding="utf-8", xml_declaration=True)
|
|
||||||
print(f"✅ wrote {dst}")
|
|
||||||
PY
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Generate a local appcast and serve it over HTTP for Sparkle testing without
|
|
||||||
# publishing a new release. Sparkle disallows file:// feeds, so we use
|
|
||||||
# http://localhost:${PORT}/appcast-local.xml.
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
PORT="${PORT:-8000}"
|
|
||||||
WORKDIR="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$WORKDIR"' EXIT
|
|
||||||
|
|
||||||
"$ROOT_DIR/scripts/make_local_appcast.sh" "${1:-$ROOT_DIR/Sparkle/appcast.xml}" "$WORKDIR/appcast-local.xml"
|
|
||||||
|
|
||||||
echo "🍏 Serving local appcast on http://127.0.0.1:${PORT}/appcast-local.xml"
|
|
||||||
echo "Set SUFeedURL to that URL, then launch iKeyMon and check for updates."
|
|
||||||
echo
|
|
||||||
echo "To set it:"
|
|
||||||
echo " defaults write net.24unix.iKeyMon SUFeedURL \"http://127.0.0.1:${PORT}/appcast-local.xml\""
|
|
||||||
echo " killall iKeyMon 2>/dev/null; open /Applications/iKeyMon.app"
|
|
||||||
echo
|
|
||||||
echo "Press Ctrl+C to stop."
|
|
||||||
|
|
||||||
cd "$WORKDIR"
|
|
||||||
python3 -m http.server "$PORT"
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"marketing_version": "26.0.40"
|
"marketing_version": "26.0.41"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user