Feat/automatic update installation (#5345)
Signed-off-by: AdemolaAri <ademola.ari@gmail.com>
This commit is contained in:
committed by
GitHub
parent
8e9b6c9bc6
commit
c3c71a2ff4
@@ -170,11 +170,19 @@ const PairRouteWrapper = ({
|
||||
const SettingsRoute = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const setView = useNavigation();
|
||||
|
||||
// Get viewOptions from location.state or history.state
|
||||
// Get viewOptions from location.state, history.state, or URL search params
|
||||
const viewOptions =
|
||||
(location.state as SettingsViewOptions) || (window.history.state as SettingsViewOptions) || {};
|
||||
|
||||
// If section is provided via URL search params, add it to viewOptions
|
||||
const sectionFromUrl = searchParams.get('section');
|
||||
if (sectionFromUrl) {
|
||||
viewOptions.section = sectionFromUrl;
|
||||
}
|
||||
|
||||
return <SettingsView onClose={() => navigate('/')} setView={setView} viewOptions={viewOptions} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Loader2, Download, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
|
||||
@@ -29,6 +29,9 @@ export default function UpdateSection() {
|
||||
currentVersion: '',
|
||||
});
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [isUsingGitHubFallback, setIsUsingGitHubFallback] = useState<boolean>(false);
|
||||
const progressTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastProgressRef = React.useRef<number>(0); // Track last progress to prevent backward jumps
|
||||
|
||||
useEffect(() => {
|
||||
// Get current version on mount
|
||||
@@ -47,6 +50,11 @@ export default function UpdateSection() {
|
||||
}
|
||||
});
|
||||
|
||||
// Check if using GitHub fallback
|
||||
window.electron.isUsingGitHubFallback().then((isGitHub) => {
|
||||
setIsUsingGitHubFallback(isGitHub);
|
||||
});
|
||||
|
||||
// Listen for updater events
|
||||
window.electron.onUpdaterEvent((event) => {
|
||||
console.log('Updater event:', event);
|
||||
@@ -63,6 +71,10 @@ export default function UpdateSection() {
|
||||
latestVersion: (event.data as UpdateEventData)?.version,
|
||||
isUpdateAvailable: true,
|
||||
}));
|
||||
// Check if GitHub fallback is being used
|
||||
window.electron.isUsingGitHubFallback().then((isGitHub) => {
|
||||
setIsUsingGitHubFallback(isGitHub);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'update-not-available':
|
||||
@@ -73,10 +85,29 @@ export default function UpdateSection() {
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'download-progress':
|
||||
case 'download-progress': {
|
||||
setUpdateStatus('downloading');
|
||||
setProgress((event.data as UpdateEventData)?.percent || 0);
|
||||
|
||||
// Get the new progress value (ensure it's a valid number)
|
||||
const rawPercent = (event.data as UpdateEventData)?.percent;
|
||||
const newProgress = typeof rawPercent === 'number' ? Math.round(rawPercent) : 0;
|
||||
|
||||
// Only update if progress increased (prevents backward jumps from out-of-order events)
|
||||
if (newProgress > lastProgressRef.current) {
|
||||
lastProgressRef.current = newProgress;
|
||||
|
||||
// Cancel any pending update
|
||||
if (progressTimeoutRef.current) {
|
||||
clearTimeout(progressTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Use a small delay to batch rapid updates
|
||||
progressTimeoutRef.current = setTimeout(() => {
|
||||
setProgress(newProgress);
|
||||
}, 50); // 50ms delay for smoother batching
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'update-downloaded':
|
||||
setUpdateStatus('ready');
|
||||
@@ -93,11 +124,19 @@ export default function UpdateSection() {
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
return () => {
|
||||
if (progressTimeoutRef.current) {
|
||||
clearTimeout(progressTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
setUpdateStatus('checking');
|
||||
setProgress(0);
|
||||
lastProgressRef.current = 0; // Reset progress tracking for new download
|
||||
|
||||
try {
|
||||
const result = await window.electron.checkForUpdates();
|
||||
@@ -123,29 +162,6 @@ export default function UpdateSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAndInstallUpdate = async () => {
|
||||
setUpdateStatus('downloading');
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
const result = await window.electron.downloadUpdate();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to download update');
|
||||
}
|
||||
|
||||
// The download progress and completion will be handled by updater events
|
||||
} catch (error) {
|
||||
console.error('Error downloading update:', error);
|
||||
setUpdateInfo((prev) => ({
|
||||
...prev,
|
||||
error: error instanceof Error ? error.message : 'Failed to download update',
|
||||
}));
|
||||
setUpdateStatus('error');
|
||||
setTimeout(() => setUpdateStatus('idle'), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const installUpdate = () => {
|
||||
window.electron.installUpdate();
|
||||
};
|
||||
@@ -216,13 +232,6 @@ export default function UpdateSection() {
|
||||
Check for Updates
|
||||
</Button>
|
||||
|
||||
{updateInfo.isUpdateAvailable && updateStatus === 'idle' && (
|
||||
<Button onClick={downloadAndInstallUpdate} variant="secondary" size="sm">
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
Download Update
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{updateStatus === 'ready' && (
|
||||
<Button onClick={installUpdate} variant="default" size="sm">
|
||||
Install & Restart
|
||||
@@ -238,21 +247,57 @@ export default function UpdateSection() {
|
||||
)}
|
||||
|
||||
{updateStatus === 'downloading' && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div className="w-full mt-2">
|
||||
<div className="flex justify-between text-xs text-text-muted mb-1">
|
||||
<span>Downloading update...</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-[width] duration-150 ease-out"
|
||||
style={{ width: `${Math.max(progress, 0)}%`, minWidth: progress > 0 ? '8px' : '0' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Update information */}
|
||||
{updateInfo.isUpdateAvailable && (
|
||||
{updateInfo.isUpdateAvailable && updateStatus === 'idle' && (
|
||||
<div className="text-xs text-text-muted mt-4 space-y-1">
|
||||
<p>Update will be downloaded to your Downloads folder.</p>
|
||||
<p className="text-xs text-amber-600">
|
||||
Note: After downloading, you'll need to close the app and manually install the update.
|
||||
</p>
|
||||
<p>Update will be downloaded automatically in the background.</p>
|
||||
{isUsingGitHubFallback ? (
|
||||
<p className="text-xs text-amber-600">
|
||||
After download, you'll need to manually install the update.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-green-600">
|
||||
The update will be installed automatically when you quit the app.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateStatus === 'ready' && (
|
||||
<div className="text-xs text-text-muted mt-4 space-y-1">
|
||||
{isUsingGitHubFallback ? (
|
||||
<>
|
||||
<p className="text-xs text-green-600">
|
||||
✓ Update is ready! Click "Install & Restart" for installation instructions.
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Manual installation required for this update method.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-green-600">
|
||||
✓ Update is ready! It will be installed when you quit Goose.
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Or click "Install & Restart" to update now.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -111,6 +111,7 @@ type ElectronAPI = {
|
||||
restartApp: () => void;
|
||||
onUpdaterEvent: (callback: (event: UpdaterEvent) => void) => void;
|
||||
getUpdateState: () => Promise<{ updateAvailable: boolean; latestVersion?: string } | null>;
|
||||
isUsingGitHubFallback: () => Promise<boolean>;
|
||||
// Recipe warning functions
|
||||
closeWindow: () => void;
|
||||
hasAcceptedRecipeBefore: (recipe: Recipe) => Promise<boolean>;
|
||||
@@ -241,6 +242,9 @@ const electronAPI: ElectronAPI = {
|
||||
getUpdateState: (): Promise<{ updateAvailable: boolean; latestVersion?: string } | null> => {
|
||||
return ipcRenderer.invoke('get-update-state');
|
||||
},
|
||||
isUsingGitHubFallback: (): Promise<boolean> => {
|
||||
return ipcRenderer.invoke('is-using-github-fallback');
|
||||
},
|
||||
closeWindow: () => ipcRenderer.send('close-window'),
|
||||
hasAcceptedRecipeBefore: (recipe: Recipe) =>
|
||||
ipcRenderer.invoke('has-accepted-recipe-before', recipe),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
dialog,
|
||||
Menu,
|
||||
MenuItemConstructorOptions,
|
||||
Notification,
|
||||
} from 'electron';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
@@ -30,6 +31,9 @@ let githubUpdateInfo: {
|
||||
// Store update state
|
||||
let lastUpdateState: { updateAvailable: boolean; latestVersion?: string } | null = null;
|
||||
|
||||
// Track last reported progress to prevent backward jumps
|
||||
let lastReportedProgress = 0;
|
||||
|
||||
// Track if IPC handlers have been registered
|
||||
let ipcUpdateHandlersRegistered = false;
|
||||
|
||||
@@ -47,9 +51,10 @@ export function registerUpdateIpcHandlers() {
|
||||
try {
|
||||
log.info('Manual check for updates requested');
|
||||
|
||||
// Reset fallback flag
|
||||
// Reset state for new update check
|
||||
isUsingGitHubFallback = false;
|
||||
githubUpdateInfo = {};
|
||||
lastReportedProgress = 0; // Reset progress tracking
|
||||
|
||||
// Ensure auto-updater is properly initialized
|
||||
if (!autoUpdater.currentVersion) {
|
||||
@@ -116,6 +121,10 @@ export function registerUpdateIpcHandlers() {
|
||||
lastUpdateState = { updateAvailable: true, latestVersion: result.latestVersion };
|
||||
updateTrayIcon(true);
|
||||
sendStatusToWindow('update-available', { version: result.latestVersion });
|
||||
|
||||
// Auto-download for GitHub fallback (matching autoDownload behavior)
|
||||
log.info('Auto-downloading update via GitHub fallback...');
|
||||
await githubAutoDownload(result.downloadUrl!, result.latestVersion!, 'manual check');
|
||||
} else {
|
||||
updateAvailable = false;
|
||||
lastUpdateState = { updateAvailable: false };
|
||||
@@ -149,12 +158,17 @@ export function registerUpdateIpcHandlers() {
|
||||
try {
|
||||
if (isUsingGitHubFallback && githubUpdateInfo.downloadUrl && githubUpdateInfo.latestVersion) {
|
||||
log.info('Using GitHub fallback for download...');
|
||||
lastReportedProgress = 0; // Reset progress tracking
|
||||
|
||||
const result = await githubUpdater.downloadUpdate(
|
||||
githubUpdateInfo.downloadUrl,
|
||||
githubUpdateInfo.latestVersion,
|
||||
(percent) => {
|
||||
sendStatusToWindow('download-progress', { percent });
|
||||
// Only send if progress increased (monotonic)
|
||||
if (percent > lastReportedProgress) {
|
||||
lastReportedProgress = percent;
|
||||
sendStatusToWindow('download-progress', { percent });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -200,25 +214,26 @@ export function registerUpdateIpcHandlers() {
|
||||
throw new Error('Update file not found. Please download the update first.');
|
||||
}
|
||||
|
||||
// Show dialog to inform user about manual installation
|
||||
// Improved dialog with clearer instructions
|
||||
const dialogResult = (await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Update Downloaded',
|
||||
message: 'The update has been downloaded to your Downloads folder.',
|
||||
detail: `Please extract the zip file and move the Goose app to your Applications folder to complete the update.`,
|
||||
buttons: ['Open Downloads', 'Cancel'],
|
||||
title: 'Update Ready to Install',
|
||||
message: `Version ${githubUpdateInfo.latestVersion} is ready to install.`,
|
||||
detail: `The update has been downloaded and extracted. To complete the installation:\n\n1. Click "Open Folder" to view the new Goose.app\n2. Quit Goose (this app will close)\n3. Drag the new Goose.app to your Applications folder\n4. Replace the existing app when prompted\n\nThe update will be available the next time you launch Goose.`,
|
||||
buttons: ['Open Folder & Quit', 'Open Folder Only', 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
cancelId: 2,
|
||||
})) as unknown as { response: number };
|
||||
|
||||
if (dialogResult.response === 0) {
|
||||
// Open the extracted folder or show the zip file
|
||||
// Open folder and quit app for easy replacement
|
||||
shell.showItemInFolder(updatePath);
|
||||
|
||||
// Optionally quit the app so user can replace it
|
||||
setTimeout(() => {
|
||||
app.quit();
|
||||
}, 1000);
|
||||
}, 1500); // Give user time to see the folder open
|
||||
} else if (dialogResult.response === 1) {
|
||||
// Just open folder, don't quit
|
||||
shell.showItemInFolder(updatePath);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error installing GitHub update:', error);
|
||||
@@ -237,6 +252,10 @@ export function registerUpdateIpcHandlers() {
|
||||
ipcMain.handle('get-update-state', () => {
|
||||
return lastUpdateState;
|
||||
});
|
||||
|
||||
ipcMain.handle('is-using-github-fallback', () => {
|
||||
return isUsingGitHubFallback;
|
||||
});
|
||||
}
|
||||
|
||||
// Configure auto-updater
|
||||
@@ -273,7 +292,7 @@ export function setupAutoUpdater(tray?: Tray) {
|
||||
}
|
||||
|
||||
// Configure auto-updater settings
|
||||
autoUpdater.autoDownload = false; // We'll trigger downloads manually
|
||||
autoUpdater.autoDownload = true; // Automatically download updates when available
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Enable updates in development mode for testing
|
||||
@@ -330,7 +349,7 @@ export function setupAutoUpdater(tray?: Tray) {
|
||||
|
||||
githubUpdater
|
||||
.checkForUpdates()
|
||||
.then((result) => {
|
||||
.then(async (result) => {
|
||||
if (result.error) {
|
||||
sendStatusToWindow('error', result.error);
|
||||
} else if (result.updateAvailable) {
|
||||
@@ -345,6 +364,10 @@ export function setupAutoUpdater(tray?: Tray) {
|
||||
lastUpdateState = { updateAvailable: true, latestVersion: result.latestVersion };
|
||||
updateTrayIcon(true);
|
||||
sendStatusToWindow('update-available', { version: result.latestVersion });
|
||||
|
||||
// Auto-download for GitHub fallback (matching autoDownload behavior)
|
||||
log.info('Auto-downloading update via GitHub fallback on startup...');
|
||||
await githubAutoDownload(result.downloadUrl!, result.latestVersion!, 'on startup');
|
||||
} else {
|
||||
updateAvailable = false;
|
||||
lastUpdateState = { updateAvailable: false };
|
||||
@@ -365,6 +388,7 @@ export function setupAutoUpdater(tray?: Tray) {
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
log.info('Auto-updater: Checking for update...');
|
||||
log.info(`Auto-updater: Feed URL during check: ${autoUpdater.getFeedURL()}`);
|
||||
lastReportedProgress = 0; // Reset progress tracking for new check
|
||||
sendStatusToWindow('checking-for-update');
|
||||
});
|
||||
|
||||
@@ -421,6 +445,10 @@ export function setupAutoUpdater(tray?: Tray) {
|
||||
updateAvailable = true;
|
||||
updateTrayIcon(true);
|
||||
sendStatusToWindow('update-available', { version: result.latestVersion });
|
||||
|
||||
// Auto-download for GitHub fallback (matching autoDownload behavior)
|
||||
log.info('Auto-downloading update via GitHub fallback after error...');
|
||||
await githubAutoDownload(result.downloadUrl!, result.latestVersion!, 'after error');
|
||||
} else {
|
||||
updateAvailable = false;
|
||||
updateTrayIcon(false);
|
||||
@@ -441,16 +469,37 @@ export function setupAutoUpdater(tray?: Tray) {
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progressObj) => {
|
||||
let log_message = 'Download speed: ' + progressObj.bytesPerSecond;
|
||||
log_message = log_message + ' - Downloaded ' + progressObj.percent + '%';
|
||||
log_message = log_message + ' (' + progressObj.transferred + '/' + progressObj.total + ')';
|
||||
log.info(log_message);
|
||||
sendStatusToWindow('download-progress', progressObj);
|
||||
const roundedPercent = Math.round(progressObj.percent);
|
||||
|
||||
// Only send progress if it increased (prevents backward jumps)
|
||||
if (roundedPercent > lastReportedProgress) {
|
||||
lastReportedProgress = roundedPercent;
|
||||
|
||||
const log_message = `Download: ${roundedPercent}% (${progressObj.transferred}/${progressObj.total}) @ ${Math.round(progressObj.bytesPerSecond / 1024)} KB/s`;
|
||||
log.info(log_message);
|
||||
|
||||
sendStatusToWindow('download-progress', {
|
||||
...progressObj,
|
||||
percent: roundedPercent,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info: UpdateInfo) => {
|
||||
log.info('Update downloaded:', info);
|
||||
sendStatusToWindow('update-downloaded', info);
|
||||
|
||||
// Show native notification
|
||||
const notification = new Notification({
|
||||
title: 'Update Ready',
|
||||
body: `Version ${info.version} will be installed when you quit Goose. Click to install now.`,
|
||||
});
|
||||
notification.show();
|
||||
|
||||
// Optional: Add click handler to install immediately
|
||||
notification.on('click', () => {
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -466,6 +515,46 @@ function sendStatusToWindow(event: string, data?: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
// centralize GitHub fallback auto-download logic.
|
||||
async function githubAutoDownload(
|
||||
downloadUrl: string,
|
||||
latestVersion: string,
|
||||
contextLabel = ''
|
||||
): Promise<void> {
|
||||
// Reset progress tracking for new download
|
||||
lastReportedProgress = 0;
|
||||
|
||||
try {
|
||||
const downloadResult = await githubUpdater.downloadUpdate(
|
||||
downloadUrl,
|
||||
latestVersion,
|
||||
(percent) => {
|
||||
// Only send if progress increased (monotonic)
|
||||
if (percent > lastReportedProgress) {
|
||||
lastReportedProgress = percent;
|
||||
sendStatusToWindow('download-progress', { percent });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (downloadResult.success && downloadResult.downloadPath) {
|
||||
githubUpdateInfo.downloadPath = downloadResult.downloadPath;
|
||||
githubUpdateInfo.extractedPath = downloadResult.extractedPath;
|
||||
sendStatusToWindow('update-downloaded', { version: latestVersion });
|
||||
} else {
|
||||
log.error(
|
||||
`GitHub auto-download failed${contextLabel ? ` (${contextLabel})` : ''}:`,
|
||||
downloadResult.error
|
||||
);
|
||||
}
|
||||
} catch (downloadError) {
|
||||
log.error(
|
||||
`Error during GitHub auto-download${contextLabel ? ` (${contextLabel})` : ''}:`,
|
||||
downloadError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function updateTrayIcon(hasUpdate: boolean) {
|
||||
if (!trayRef) return;
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ export class GitHubUpdater {
|
||||
log.info(`GitHubUpdater: Looking for asset named: ${assetName}`);
|
||||
log.info(`GitHubUpdater: Available assets: ${release.assets.map((a) => a.name).join(', ')}`);
|
||||
|
||||
const asset = release.assets.find((a) => a.name === assetName);
|
||||
const asset = release.assets.find((a) => a.name.toLowerCase() === assetName.toLowerCase()); // keeping comparison to lower case becasue Goose vs goose
|
||||
if (asset) {
|
||||
downloadUrl = asset.browser_download_url;
|
||||
log.info(`GitHubUpdater: Found matching asset: ${asset.name} (${asset.size} bytes)`);
|
||||
@@ -115,6 +115,12 @@ export class GitHubUpdater {
|
||||
log.warn(`GitHubUpdater: No matching asset found for ${assetName}`);
|
||||
}
|
||||
|
||||
if (!downloadUrl) {
|
||||
throw new Error(
|
||||
`Update Available but no download URL found for platform: ${platform}, arch: ${arch}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
updateAvailable: true,
|
||||
latestVersion,
|
||||
@@ -159,6 +165,7 @@ export class GitHubUpdater {
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
let lastReportedPercent = -1; // Track last reported percentage to throttle updates
|
||||
|
||||
// Read the response stream
|
||||
const reader = response.body.getReader();
|
||||
@@ -172,10 +179,15 @@ export class GitHubUpdater {
|
||||
chunks.push(value);
|
||||
downloadedSize += value.length;
|
||||
|
||||
// Report progress
|
||||
// Report progress - only when percentage changes by at least 1%
|
||||
if (totalSize > 0 && onProgress) {
|
||||
const percent = Math.round((downloadedSize / totalSize) * 100);
|
||||
onProgress(percent);
|
||||
|
||||
// Only report if percent changed (throttles from hundreds/sec to ~100 total)
|
||||
if (percent !== lastReportedPercent) {
|
||||
onProgress(percent);
|
||||
lastReportedPercent = percent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user