Сегодня я бы хотел рассказать про то, как сделать лаунчер (updater). Зачем это нужно спросите вы? В ответ я расскажу вам небольшую историю.

Немного истории…
За окном шел 2012 года, а я начинал свой проект в страховой компании. Поначалу это был простой клиент для выполнения нескольких действий: ведение учета изготовленных полисов и еще несколько не сложных действий. Но проект рос как на дрожжах – постепенно он стал полностью заменять «древнее» ПО.
По началу программа стояла только в офисе на 5 машинах. Если нужно было обновить ее, то ничего сложного: скинул файл «.exe» в файлопомойку и пошел, залил на все машины. Меня это не сильно утруждало. Дальше интереснее — я поставил свое ПО на компьютеры в офисе за 300 км (еще 7 машин). Вот тут понеслась жара. Обновление программы: подключиться к каждому по TeamViewer–у и скинуть новый файл «.exe». Не надо говорить, что через пару обновлений меня такое положение дел изрядно достало.
Именно в тот момент меня озарило – если вы пишите даже несложный проект, вы будете делать патчи или обновления. Скидывать вручную эти обновления – это маразм, необходима автоматизация. Спасибо, за помощь и идею данного решения моему товарищу Олегу Чередниченко (Format_C_eft).

Схема решения Update…
Схема проста, но вмести с тем — гениальна. Все представлено на картинке ниже, рассмотрим ее:

Схема работы обновления лаунчера updatee

После запуска Update.exe (давайте так назовем наш лаунчер) получает версию программы, которая установлена на данный момент на машине (1) и получает версию последнего релиза программы с «Сервера обновления» (2).
Сравниваем полученные версии, при реализации этой задачи я принял допущение, что на сервере всегда правильная версия. Например: локальная версия (1.0.4.65), а на сервере (1.0.4.63). Сравнивая эти две версии, с учетом описанного допущения программа запустит обновление.
Плюсы такого подхода:
а) возможность без проблемно откатить обновление. Представим, вы выпустили обновление, но оно не удачное. Пользователи вопят, что не могут работать, и им не объяснить, что необходимо время на исправление проблемы. Самый лучший вариант, вернуть предыдущую версию и заняться исправлением багов обновления. В этом случае просто изменяем версию на сервере на предыдущую версию.
б) простота реализации. Как давно уже известно, отказоустойчивость системы обратно пропорциональна ее сложности.

Сравнение версий…
Перейдем непосредственно к коду. Сравнения двух версий.

function compare_ver(ver_old,ver_basa:String):Boolean; //Возвращает истину если версии не
var                                                    //равны, ложь - версии равны.
v_o1,v_o2,v_o3,v_o4:String;     //Версия принимается в виде X.X.Х.Х
v_b1,v_b2,v_b3,v_b4:String;
i1,i2:Integer;
begin
v_o1:=copy(ver_old,1,(Pos('.',(ver_old))-1));
delete(ver_old,1,Pos('.',(ver_old)));
v_o2:=copy(ver_old,1,(Pos('.',(ver_old))-1));
delete(ver_old,1,Pos('.',(ver_old)));
v_o3:=copy(ver_old,1,(Pos('.',(ver_old))-1));
delete(ver_old,1,Pos('.',(ver_old)));
v_o4:=ver_old;

v_b1:=copy(ver_basa,1,(Pos('.',(ver_basa))-1));
delete(ver_basa,1,Pos('.',(ver_basa)));
v_b2:=copy(ver_basa,1,(Pos('.',(ver_basa))-1));
delete(ver_basa,1,Pos('.',(ver_basa)));
v_b3:=copy(ver_basa,1,(Pos('.',(ver_basa))-1));
delete(ver_basa,1,Pos('.',(ver_basa)));
v_b4:=ver_basa;

i1:=StrToInt(v_o4);
i2:=StrToInt(v_b4);
if  i1<>i2 then
begin
  Result:=True;
end
else
begin
  i1:=StrToInt(v_o3);
  i2:=StrToInt(v_b3);

  if  i1<>i2 then
  begin
   Result:=True;
  end
  else
  begin
    i1:=StrToInt(v_o2);
    i2:=StrToInt(v_b2);

    if  i1<>i2 then
    begin
     Result:=True;
    end
    else
    begin
      i1:=StrToInt(v_o1);
      i2:=StrToInt(v_b1);
      if  i1<>i2 then
      begin
       Result:=True;
      end
      else
      begin
       Result:=False;
      end;
    end;
  end;
end;

end;

Как же получить версию локального файла? Признаюсь, не помню, откуда я взял этом модуль. Здесь просто приведу пример. В Windows есть version.dll. Используя эту библиотеку можно получить версию файла прописанную в атрибутах следующих типов «.exe» и «.dll». Важный момент: в uses прописать модуль VerInfo. Я приложу его в сорцах в конце статьи.
Вот собственно и пример.

Uses VerInfo;
function FileVersion(AFileName: string): string;
var
  szName: array[0..255] of Char;
  P: Pointer;
  Value: Pointer;
  Len: UINT;
  GetTranslationString: string;
  FFileName: PChar;
  FValid: boolean;
  FSize: DWORD;
  FHandle: DWORD;
  FBuffer: PChar;
begin
  try
    FFileName := StrPCopy(StrAlloc(Length(AFileName) + 1), AFileName);
    FValid := False;
    FSize := GetFileVersionInfoSize(FFileName, FHandle);
    if FSize > 0 then
    try
      GetMem(FBuffer, FSize);
      FValid := GetFileVersionInfo(FFileName, FHandle, FSize, FBuffer);
    except
      FValid := False;
      raise;
    end;
    Result := '';
    if FValid then
      VerQueryValue(FBuffer, '\VarFileInfo\Translation', p, Len)
    else
      p := nil;
    if P <> nil then
      GetTranslationString := IntToHex(MakeLong(HiWord(Longint(P^)),
        LoWord(Longint(P^))), 8);
    if FValid then
    begin
      StrPCopy(szName, '\StringFileInfo\' + GetTranslationString +
        '\FileVersion');
      if VerQueryValue(FBuffer, szName, Value, Len) then
        Result := StrPas(PChar(Value));
    end;
  finally
    try
      if FBuffer <> nil then
        FreeMem(FBuffer, FSize);
    except
    end;
    try
      StrDispose(FFileName);
    except
    end;
  end;
end;

Бэкапим..Качаем…Проверяем….

Далее, наверное самый интересный момент это реализации. Просто смотрите код, я все поясню в комментариях.

procedure Update_client;
var
p,path_old,path_new,path_basa,md5_zag,md5_basa,sql:string;

begin
  path_old:= ExtractFilePath(ParamStr(0));
  path_old:=path_old+'client.exe';  //Путь к файлу программы
  if not FileExists(path_old) then //Это проверка существует файл или нет
  begin                           //Встречался такой момент когда из-за плохого соединения 
//программа скачивания зависает и в итоге файл не доканчивается, а старый в олдах
//При следующем запуске она не может найти client.exe и возникает ошибка.
    Error_Diconnect;
  end;

  version_old:=FileVersion(path_old);      //Текущая версия
  if Length(version_old)=0 then //На случай если не удалось получить версию файла
  begin
   form1.Label1.Caption:='Ошибка получения версии файла.'+#13+' Сообщите в IT отдел.';
   Application.ProcessMessages;
   exit;
  end;

  if form1.ZConnection1.Connected=False then //Проверка состояние конекта Zeosdbo
  begin
   form1.Label1.Caption:='Ошибка соединения.'+#13+' Проверьте подключение.';
   Application.ProcessMessages;
   exit;
  end; //Программа не умела автоматически реконектиться
  version_basa:=form1.tb.FieldByName('ver').AsString; //Версия хранимая в базе

  form1.Label1.Caption:='Идет проверка обновления...';
  Application.ProcessMessages;
  if compare_ver(version_old,version_basa) then
  begin
   update_prog; //Запуск обновления
  end
  else
  begin
    path_old:= ExtractFilePath(ParamStr(0));
    path_old:=path_old+'client.exe';

    WinExec(Pchar(path_old), SW_SHOW); //Запуск программы и закрытие лаучера (update)
    form1.qr_up.Close;
    form1.tb.Close;
    form1.ZConnection1.Disconnect;
    Application.ProcessMessages;
    Application.Terminate;
  end;

end;

Ну а вот самое сердце всей программы обновления проекта.

procedure update_prog;
var
p,path_old,path_new,path_basa,md5_zag,md5_basa,sql:string;
VerInfoR  : TVerInfoRes;
begin
	  form1.Label1.Caption:='Требуется обновление...';
    Application.ProcessMessages;
    path_old:= ExtractFilePath(ParamStr(0));
    path_old:=path_old+'client.exe';  //Путь к файлу программы

    path_new := ChangeFileExt(path_old, '.old');  //Создание олда

    if RenameFile(path_old, path_new)
    then
    begin //Успешное создание олда
     form1.Label1.Caption:='Создание копии старой версии...';
     Application.ProcessMessages;
     path_basa:=form1.tb.FieldByName('path').AsString;  //Путь к обновлению

     path_new:= ExtractFilePath(ParamStr(0));
     path_new:=path_new+'client.exe';      //Путь к тому, что обновляем
     form1.Label1.Caption:='Обновление программы...';
     Application.ProcessMessages;
     if GetInetFile(path_basa, path_new)=True then
     begin

       md5_basa:=form1.tb.FieldByName('md5').AsString; //Хеш в базе

       path_old:= ExtractFilePath(ParamStr(0));
       path_old:=path_old+'client.exe';
       md5_zag:=MD5DigestToStr(MD5File(path_old)); //Хеш загруженного файла
       form1.Label1.Caption:='Проверка целостности обновления...';
       Application.ProcessMessages;
       if md5_zag=md5_basa then
       begin

        path_new:= ExtractFilePath(ParamStr(0));
        path_new:=path_new+'clients.old';
        form1.Label1.Caption:='Удаление старой версии...';
        Application.ProcessMessages;
        if DeleteFile(Pchar(path_new)) then   //Удаление олда
        begin
           form1.qr_up.SQL.Clear; //Этот кусок повторяется для того чтобы в текущем соединении с 
            //сервером MySql в Zeosdbo получать кирилицу в нормальной кодировке
           form1.qr_up.SQL.Add('SET Names cp1251');
           form1.qr_up.ExecSQL;

           sql:='insert into log_update (data_up,comp_name,Ip_adress,Mac_adress,status) 
values ('+Chr(39)+Data_Deis+Chr(39)+', '+Chr(39)+GetComputerNetName+Chr(39)+', '
+Chr(39)+GetLocalIP+Chr(39)+', '+Chr(39)+GetMACAdress+Chr(39)+', '+Chr(39)+
'Обновление успешно завершено! До версии:'+version_basa+Chr(39)+')';
           form1.qr_up.SQL.Clear;
           form1.qr_up.SQL.Add(sql);
           form1.qr_up.ExecSQL;

           path_old:= ExtractFilePath(ParamStr(0));
           path_old:=path_old+'clients.exe';

           WinExec(Pchar(path_old), SW_SHOW);
           form1.qr_up.Close;
           form1.tb.Close;
           form1.ZConnection1.Disconnect;
           Application.ProcessMessages;
           Application.Terminate;
        end
        else
        begin
           form1.qr_up.SQL.Clear;
           form1.qr_up.SQL.Add('SET Names cp1251');
           form1.qr_up.ExecSQL;

           sql:='insert into log_update (data_up,comp_name,Ip_adress,Mac_adress,status) values ('+
Chr(39)+Data_Deis+Chr(39)+', '+Chr(39)+GetComputerNetName+Chr(39)+', '+Chr(39)+GetLocalIP+Chr(39)+
', '+Chr(39)+GetMACAdress+Chr(39)+', '+Chr(39)+'Ошибка удаления old : '+IntToStr(GetLastError)+
' Версия установленая:'+version_old+' Актуальная:'+version_basa+Chr(39)+')';
           form1.qr_up.SQL.Clear;
           form1.qr_up.SQL.Add(sql);
           form1.qr_up.ExecSQL;

           Last_Old;
        end;
       end
       else
       begin
           form1.qr_up.SQL.Clear;
           form1.qr_up.SQL.Add('SET Names cp1251');
           form1.qr_up.ExecSQL;

           sql:='insert into log_update (data_up,comp_name,Ip_adress,Mac_adress,status) values ('+
Chr(39)+Data_Deis+Chr(39)+', '+Chr(39)+GetComputerNetName+Chr(39)+', '+Chr(39)+GetLocalIP+Chr(39)+
', '+Chr(39)+GetMACAdress+Chr(39)+', '+Chr(39)+'Откат обновления. Не совпал md5 хэш!'+
' Версия установленная:'+version_old+' Актуальная:'+version_basa+Chr(39)+')';
           form1.qr_up.SQL.Clear;
           form1.qr_up.SQL.Add(sql);
           form1.qr_up.ExecSQL;

           Last_Old;
       end;
     end
     else
     begin
      form1.qr_up.SQL.Clear;
      form1.qr_up.SQL.Add('SET Names cp1251');
      form1.qr_up.ExecSQL;

      sql:='insert into log_update (data_up,comp_name,Ip_adress,Mac_adress,status) values ('+
Chr(39)+Data_Deis+Chr(39)+', '+Chr(39)+GetComputerNetName+Chr(39)+', '+Chr(39)+GetLocalIP+Chr(39)+
', '+Chr(39)+GetMACAdress+Chr(39)+', '+Chr(39)+
'Ошибка при загрузке файла. Версия установленная:'+version_old+' Актуальная:'+version_basa+Chr(39)+')';
      form1.qr_up.SQL.Clear;
      form1.qr_up.SQL.Add(sql);
      form1.qr_up.ExecSQL;

      Last_Old;
     end;

    end
    else
    begin
      form1.qr_up.SQL.Clear;
      form1.qr_up.SQL.Add('SET Names cp1251');
      form1.qr_up.ExecSQL;

      sql:='insert into log_update (data_up,comp_name,Ip_adress,Mac_adress,status) values ('+
Chr(39)+Data_Deis+Chr(39)+', '+Chr(39)+GetComputerNetName+Chr(39)+', '+Chr(39)+GetLocalIP+Chr(39)+
', '+Chr(39)+GetMACAdress+Chr(39)+', '+Chr(39)+'При создании old-файла произошла ошибка : '+
IntToStr(GetLastError)+' Версия установленая:'+version_old+' Актуальная:'+version_basa+Chr(39)+')';
      form1.qr_up.SQL.Clear;
      form1.qr_up.SQL.Add(sql);
      form1.qr_up.ExecSQL;
      Last_Old;
    end;
end;

Обратите внимание, что если возникают проблемы при создании, переименовании, удалении файла и так далее, выполняется процедура Last_Old – аварийной откат обновления.
Обновление качается с помощью компонента IdHTTP1. Эту функцию я где-то нашел (только, честно говоря не помню где), запилил в свою реализацию, и получился простенький лаунчер (updater).

function GetInetFile(fileURL, FileName: String):Boolean;
var
LoadStream: TMemoryStream;
begin
try
 LoadStream := TMemoryStream.Create; // выделение памяти под переменную
 form1.idHTTP1.Get(fileURL, LoadStream); // загрузка в поток данных из сети
 LoadStream.SaveToFile(FileName); // сохраняем данные из потока на жестком диске
 LoadStream.Free; // освобождаем память
except
 Result:=False;
 exit;
end;
 Result:=True;
end;

Она хорошая и простая, единственный минус – не имеет возможность докачки файла. Если процесс скачивания прерывается, при восстановлении связи скачивание начнется с начала. Процесс закачки файла можно отображать в виде прогресс бара. В это плане мне нравятся компонент Gauge, он умеет отображать процесс в процентном отношении. Чтоб процесс скачивание имел связь с компонентом Gauge, нужно описать три события компонента IdHTTP1.

События для скачивания файла компонент IdHTTP

procedure TForm1.IdHTTP1Work(Sender: TObject; AWorkMode: TWorkMode;
  const AWorkCount: Integer);
begin
Gauge1.Progress:=AWorkCount;
end;

procedure TForm1.IdHTTP1WorkBegin(Sender: TObject; AWorkMode: TWorkMode;
  const AWorkCountMax: Integer);
begin
Gauge1.Progress:=0;
Gauge1.MaxValue:=AWorkCountMax;
end;

procedure TForm1.IdHTTP1WorkEnd(Sender: TObject; AWorkMode: TWorkMode);
begin
Gauge1.Progress:=0;
end;

Немного о сервере обновления…
Сервер обновления можно условно разделить на две части.
Первая: хранилище релизов – это может быть просто ftp-сервер, ваш сайт — не важно просто место, где лежит файл и к нему можно получить доступ по url. В моем случае это ftp-сервер. На нем лежит куча папок. Имя папки – это версия программы. А в самой папке уже лежит файл. Такая система позволяет вести историю релизов. И при острой необходимости откатиться на предыдущую версию.
Вторая – хранилище информации. В базе, на сервере MySql, создаются две таблички. Первая update – таблица с данными для релиза. Вторая log_updatet – системный лог, в который программа пишет, обновилась или нет. Если не обновилась — то по какой причине.
Таблица update

CREATE TABLE `update` (
id int(10) NOT NULL AUTO_INCREMENT,
name varchar(255) DEFAULT NULL COMMENT 'Имя файла',
opisanie varchar(255) DEFAULT NULL COMMENT 'Описание файла',
ver char(15) NOT NULL DEFAULT '0' COMMENT 'Версия',
path varchar(1000) NOT NULL DEFAULT '0' COMMENT 'Путь с серваку',
md5 varchar(1000) NOT NULL DEFAULT '0' COMMENT 'Хэш',
PRIMARY KEY (id)
)
ENGINE = INNODB
AUTO_INCREMENT = 25
AVG_ROW_LENGTH = 682
CHARACTER SET cp1251
COLLATE cp1251_general_ci
COMMENT = 'Таблица обновления ';

Таблица log_update

CREATE TABLE log_update (
id int(10) NOT NULL AUTO_INCREMENT,
data_up datetime DEFAULT NULL,
comp_name char(255) DEFAULT '0',
status char(255) DEFAULT NULL,
ip_adress char(100) DEFAULT '0.0.0.0',
MAC_adress char(100) DEFAULT '-',
PRIMARY KEY (id)
)
ENGINE = INNODB
AUTO_INCREMENT = 8716
AVG_ROW_LENGTH = 985
CHARACTER SET cp1251
COLLATE cp1251_general_ci
COMMENT = 'Лог обновления программы';

Ну вот, в принципе я описал все тонкие моменты реализации лаунчера (updater). В следующей статье я хотел бы рассказать о том, как реализовать лаунчер с докачкой файла. Причем реализацию скачивания списка файлов, а не одного. Как обычно исходники здесь. С уважением, ваш Shinobi.

8 комментариев

  1. Станислав

    Что же Вы от человеческого-то фактора ошибки не избавляетесь? )
    Что будет, если на сервер обновлений попадет версия, более старая, чем текущая у клиента? Обновится, да?)

    Ответить
  2. admin_shinobi

    Я смотрю просто чуть дальше чем вы сейчас))) Представим ситуацию вы выпустили обновление, все обновились а там серьезный баг…Который с наскока вы не исправите. А у вас функция ограниченна и исключает то что вы назвали «человеческим фактором». И что делать? В моем случае я просто ставлю предыдущую рабочую версию и начинаю работу над багом….Как вам такой поход?)

    Ответить
    • Илья

      там можно, используя защиту от человеческого фактора сделать не откат на предыдущую версию, а предыдущую версию переобозвать следующей.
      Например, была версия 1.5.
      Сделали версию 1.6. Она оказалась косячная и сразу исправить ошибку не получилось.
      Берем старую версию, обзываем ее 1.6.1 (то есть, уже более старшая по сравнению с косячной 1.6) и выкладываем на сервер.
      программа автоматически обновляется с 1.6 на 1.6.1.

      Пользователи спокойно обновляются. А что бы пресечь гневные крики пользователей в случае визуального отличия 1.5 и 1.6 (соответственно и отличие 1.6 и 1.6.1), можно сделать сопроводительный файл, например — что нового. В котором и описать возникшую ситуацию. Что мол, извините, косяков так много, что рекомендуем с версии 1.6 перейти на 1.6.1, визуально похожую на версию 1.5
      🙂

      Ответить
      • admin_shinobi

        Да идея интересная..Но 1.- Версия файлов в винде,я использовал Api-функцию поставляется именно в формате «X.X.X.X». Поэтому в логику программы я закладывал такой шаблон. 2.-Визуальное отображение версии. Често я об этом не задумывался так как мои конечные пользователи это страховые агенты у которых только в резюме написано «уверенный пользователь». На деле они простейший инструкцию не могут запомнить. 3-«Патч новости» — это вообще отдельная тема…Вот ситуация с жизни. Когда выпускаю обновление, то просто в корпоративном чате для всех пользователей пишу рассылку типа обновили то-то, закрыли те-те функции при тех-тех условиях. Ну собственно сделал обновление, рассылку отправил..Несколько девчонок поняли молодых…Приходит начальница страхового отдела и говорит а можно переписать рассылку как для трех-летних…Я не чего не поняла. Хотя на деле она даже не читала. Ну это лирика. На деле я откат применял всего пару раз за неколько лет. В общем это просто запасной вариант. Всегда нужно иметь запасной вариант)))Разве вы так не считаете?

        Ответить
  3. Илья

    Тогда можно сделать такой «хитрый» ход.
    Если Api-функция поставляет данные о версии именно в формате «X.X.X.X», то первые три символа использовать как номер версии.
    А вот последний, четвертый, указывать — 0 или 1 (аналог значений ложь и истина). При этом:
    — если 1 (истина), то версия закачивается без проверки, то есть, что лежит на сервере, то и качаем и устанавливаем.
    — если 0 (ложь), то перед скачиванием проверяем, что на сервере лежит более свежая версия (что бы исключить человеческий фактор).

    Рассмотрим ситуацию из моего предыдущего примера.
    Была версия 1.5. Мы ее нумеруем 1.5.0.0.
    Выпустили новую версию 1.6. Нумеруем 1.6.0.0
    Она (1.6.0.0) оказалась косячная и сразу исправить ошибку не получилось.
    Берем нормальную (предыдущую) версию 1.5.0.0 и присваиваем ей номер 1.5.0.1. Закачиваем на сервер для обновления версию 1.5.0.1.
    При обновлении программа обновления проверяет номер версии, определяет, что версия на сервере имеет номер 1.5.0.1 — то есть, последний знак равен 1. И закачивает эту версию 1.5.0.1 вместо ранее выпущенной 1.6.0.0.

    В этой схеме, понятно, человеческий фактор будет исключен не полностью, ибо, накосячив с последним знаком (который 0 или 1), можно и алгоритм обработки завернуть не в ту степь 🙂

    Если пользователи такие тупые, как Вы пишете, то им и пояснять ничего не надо.

    Спасибо Вам за код. Буду с ним разбираться, так как у меня возникла такая же задача для своей программы. Только в моем случае, пользователи могут быть умными и проверять номер той версии, что было и что стало 🙂

    Ответить
    • admin_shinobi

      Во первых спасибо вам за подобные коментарии. Это позволяет развиваться и учиться. У меня есть пример другой схемы обновления, я поже опишу. Правда там там такая жесть писал почти 3 недели с перерывами и то до сих пор не понимаю как работает. Думаю в конце недели выложу статью.

      Ответить

  4. Хорошей практикой было бы совместить апдейтер с самой программой. Алгоритм такой: пользователь запускает программу, она смотрит, не нужно ли ей обновиться, если нужно — качает обновление, сохраняет его, создает bat-файл, который подменяет exe-шники, запускает его, сама закрывается, bat-файл подменяет файлы, запускает программу, программа удаляет bat-ник.

    ЗЫ Код написан не очень красиво. Куча копипастов — выполнение insert’а, например, можно было бы сделать в одном месте, экранирование через Chr(39) — тоже странно. Автор, видимо, не знает, что в строке можно написать два апострофа, а так же, что служебные символы в запросе, в строках, тоже нужно экранировать (что будет, если, теоретически, в имени компьютера пользователя окажется знак апострофа?).
    Много строковых констант по коду, например client.exe

    Ответить
    • admin_shinobi

      Спасибо за ценные замечания. Но это было пару лет, я тогда только начинал профессионально писать. Сейчас я использую другой апдейтор. Все руки не дайдут написать статью более обширную на эту тему. Я специально использую апдейтор отдельно, потому как переписывал весь проект написанный на Delphi 7 и все было в одной exe. Переписал все на раздельные модуми, и часть перенес в сохраненные процедуры и справочники. Такой подход позволил сделать его более динамичным. Вот апдейтор я задумал как динамичный он работает в отдельном потоке, через http (так в некоторых мечтах где стоит программа, остальные порты закрыты). Апдейтор работает со специальным списком структуры, в соответствии с которым он проверяет содержимое папки с программой. Работает докачка файлов не просто с момента обрыва соединения. Можно закрыть его, и после открытия о продолжит докачку файлов.

      Ответить

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Этот сайт использует Akismet для борьбы со спамом. Узнайте как обрабатываются ваши данные комментариев.