Terminator 4
© 2003 Алексей Морозов
Парадоксальные задачи рождают парадоксальные решения... Именно таким задачам обязана своим появлением на свет утилита Terminator, которой и посвящена данная статья.
О каких же необычных задачах идет речь?
- Предотвратить появление заданного окна на экране;
- Постоянно поддерживать в запущенном состоянии указанную программу;
- Быть незаметной для пользователя и использовать минимум ресурсов компьютера, так как предполагается постоянное выполнение данной утилиты (резидентность).
Вы уже, наверное, догадались, что типичное оформление подобной утилиты - иконка в "трее" (Tray Icon). Таким образом, главная форма программы является отличным кандидатом на диалог конфигурирования параметров и большую часть времени остается невидимой. Чтобы не допустить повторного запуска утилиты, воспользуемся именованным "взаимным исключением" (Mutex). Исходный файл проекта (Terminator.dpr) будет выглядеть следующим образом:
program Terminator;
uses
Windows,
Forms,
Main in 'Main.pas' {MainForm};
{$R *.res}
var
MutexHandle: THandle;
begin
MutexHandle := CreateMutex(nil, True, 'Terminator'); // Создаем именованный Mutex
if MutexHandle <> 0 then // Получен корректный дескриптор Mutex?
begin
if GetLastError <> ERROR_ALREADY_EXISTS then // Mutex был создан, а не открыт уже существующий?
begin
Application.Initialize;
Application.title := 'Terminator';
Application.CreateForm(TMainForm, MainForm);
Application.ShowMainForm := False; // Запрещаем показ главной формы при запуске программы
Application.Run;
end;
CloseHandle(MutexHandle); // Освобождаем дескриптор Mutex
end
else
Application.MessageBox('Unable to determine presence of application instance!',
'Terminator', MB_OK or MB_ICONERROR);
end.
Чтобы уменьшить нагрузку на процессор, будем выполнять всю основную работу в "нити" (Thread) низкого приоритета. С решением задачи о недопущении показа определенного окна трудностей возникнуть не должно: в теле главного цикла нити вызываем Win32 API функцию FindWindow с нужными нам параметрами (можно искать окно указанного класса, с указанным заголовком или все вышеперечисленное вместе). Если функция вернула существующий дескриптор окна, немедленно закрываем это окно, послав ему сообщение WM_CLOSE (предварительно также стоит послать окну сообщение WM_CANCELMODE, которое в случае закрытия диалогового окна вызовет действия, аналогичные нажатию на кнопку "Cancel"). Чтобы главный цикл нити не загружал процессор постоянными проверками, в конце каждой итерации вызовем Win32 API функцию Sleep на короткий промежуток времени (около 250 миллисекунд для данной задачи будет достаточно). Таким образом, отслеживаемое окно может показаться на экране на время не более четверти секунды, а затем исчезнет на глазах изумленной публики (на самом деле, описываемая утилита должна была имитировать сбой в работе системы генерации отчетов некой клиентской программы. Отчеты можно было выдавать либо в MS Excel, либо печатать через форму просмотра отчетов. Разработчики использовали QuickReport и стандартную форму предварительного просмотра, поэтому слежение за окнами с классом "TQRStandardPreview" позволило создать натуральную иллюзию неработоспособности системы печати отчетов, вынуждая пользователя экспортировать данные в MS Excel, а дальше... Но это "дальше" уже выходит за тему, обсуждаемую в данной статье).
Внимательный читатель мог уже догадаться, что именно MS Excel нужно было поддерживать в состоянии постоянной загрузки (только так можно заставить работать подключенные Add-in'ы, так как при запуске MS Excel через OLE-автоматизацию загружается лишь минимальное ядро без каких-либо дополнительно установленных элементов. Господа разработчики, использующие приложения MS Office через OLE-автоматизацию - не забывайте об этой неприятной особенности!)
Далее, задачу по поддержанию в постоянной готовности какой-либо программы можно решить следующим образом: произвести поиск среди исполняющихся в настоящий момент процессов, и если программа в памяти не обнаружена, запустить ее в минимизированном состоянии. В теле главного цикла нити можно использовать Win32 API функцию WaitForSingleObject с дескриптором запущенного процесса в качестве параметра. При успешном завершении функции (код возврата равен константе WAIT_OBJECT_0) перезапускаем нужный нам процесс. Функция WaitForSingleObject ожидает в качестве второго параметра промежуток времени в миллисекундах, после которого она завершиться по тайм-ауту. Самое время избавиться от функции Sleep, а ее параметр использовать в WaitForSingleObject. Запустить же новый процесс совсем несложно, используя Win32 API функцию CreateProcess:
function ExecProcess(const ModuleName: string): THandle;
var
StartupInfo: TStartupInfo;
ProcessInfo: TProcessInformation;
begin
FillChar(StartupInfo, SizeOf(StartupInfo), 0); // Заполняем структуру нулями
with StartupInfo do
begin
cb := SizeOf(StartupInfo); // Поле длины структуры обязательно к заполнению
dwFlags := STARTF_USESHOWWINDOW; // Используем лишь одно поле wShowWindow
wShowWindow := SW_SHOWMINNOACTIVE; // Главное окно процесса появится минимизированным и неактивным
end;
if CreateProcess(PChar(ModuleName), nil, nil, nil, False, NORMAL_PRIORITY_CLASS,
nil, nil, StartupInfo, ProcessInfo) then
begin
CloseHandle(ProcessInfo.hThread); // Освобождаем дескриптор главной нити
Result := ProcessInfo.hProcess; // Возвращаем дескриптор процесса
//(не забудьте затем освободить этот дескриптор)
end
else
Result := 0; // В случае ошибки возвращаем недействительное значение дескриптора процесса
end;
Подготавливаем структуру типа TStartupInfo таким образом, чтобы новый процесс запускался минимизированным и не получал фокус ввода после старта. Функция CreateProcess заполняет структуру типа TProcessInformation, содержащую дескрипторы как самого процесса, так и его главной нити. Дескриптор главной нити нам не интересен, поэтому освобождаем его посредством вызова Win32 API функции CloseHandle. А вот дескриптор процесса позволит нам организовать наблюдение за завершением этого процесса с помощью упомянутой ранее функции WaitForSingleObject. Замечу также, что дескриптор процесса, возвращенный функцией CreateProcess, имеет разрешение доступа PROCESS_ALL_ACCESS, включающее в себя SYNCHRONIZE, необходимое для отслеживания завершения процесса.
Но как найти процесс, если он уже исполняется на момент запуска нити мониторинга (чтобы не запускать несколько копий приложения)? Давайте используем функции Win32 из раздела SDK Process Status Helper (PSAPI). Сразу замечу, что функции PSAPI реализованы в Windows NT/2000/XP/.Net, так что счастливым обладателям Windows 95/98/ME придется использовать функции SDK Tool Help. Почему именно PSAPI, ведь Tool Help тоже поддерживается на платформах NT/2000/XP/.Net? Уверен, что большинство из читателей не знакомы с этими функциями, так почему бы не начать их применять?!
uses PsApi;
function FindProcess(const ModuleName: string): THandle;
const
MaxProcesses = 1024;
var
Processes: array[0 .. MaxProcesses - 1] of DWORD; // Массив идентификаторов запущенных процессов
ModuleHandle: HMODULE; // Буфер для дескриптора главного модуля процесса
cbNeeded: DWORD;
ProcessName: array[0 .. MAX_PATH - 1] of Char; // Буфер для получения имени главного модуля процесса
I: Integer;
begin
// Заполняем массив идентификаторов запущенных процессов
if EnumProcesses(@Processes, SizeOf(Processes), cbNeeded) then
for I := 0 to cbNeeded div SizeOf(DWORD) - 1 do // Организуем цикл по каждому элементу массива
begin
// Получаем дескриптор процесса по его идентификатору
Result := OpenProcess(PROCESS_QUERY_INFORMATION or PROCESS_VM_READ or SYNCHRONIZE, False, Processes[I]);
if Result <> 0 then // Попытка увенчалась успехом?
begin
// Получаем дескриптор первого (главного) модуля процесса
if EnumProcessModules(Result, @ModuleHandle, SizeOf(ModuleHandle), cbNeeded) then
begin
// Извлекаем имя файла главного модуля процесса
GetModuleFileNameEx(Result, ModuleHandle, ProcessName, SizeOf(ProcessName));
if lstrcmpi(ProcessName, PChar(ModuleName)) = 0 then
Exit; // При положительном результате сравнения возвращаем полученный дескриптор процесса
//(не забудьте затем освободить его)
end;
CloseHandle(Result); // Освобождаем дескриптор процесса
end;
end;
Result := 0; // Искомый процесс не найден - возвращаем недействительное значение дескриптора процесса
end;
С помощью PSAPI функции EnumProcesses мы получаем массив идентификаторов всех процессов, запущенных в текущий момент времени (не путайте, пожалуйста, идентификатор процесса с дескриптором процесса). Единственное замечание, что массив, переданный в качестве параметра функции, должен быть достаточно большим, так как нет способа узнать заранее необходимое количество элементов (в нашем случае он может вмещать до 1024 идентификаторов). Пройдемся в цикле по возвращенным идентификаторам процессов. С помощью Win32 API функции OpenProcess попытаемся получить дескриптор процесса из его идентификатора. Почему именно попытаемся? Среди списка всех процессов есть системные процессы, запущенные с помощью системных учетных записей (SYSTEM, LOCAL SERVICE, NETWORK SERVICE etc.) Попытка их открытия с разрешением доступа "лучшим" чем PROCESS_QUERY_INFORMATION приведет к ошибке отказа в доступе (Win32 error 5: Access denied). Но, к счастью, эти процессы для нашей задачи интереса не представляют (управлять ими все равно нет никакой нетривиальной возможности). Нам необходимо получить дескриптор процесса с разрешениями доступа, включающими PROCESS_QUERY_INFORMATION и PROCESS_VM_READ для извлечения списка модулей с помощью PSAPI функции EnumProcessModules. Каждый процесс может иметь несколько ассоциированных с ним модулей, но первым из них всегда является исполняемый файл процесса, поэтому мы получаем только первый модуль из списка (другими модулями могут быть различные DLL библиотеки и т.п.) Из дескриптора модуля несложно получить полное имя исполняемого файла модуля. Win32 API функция GetModuleFileNameEx нам в этом поможет. Осталось сравнить полученное имя файла с заранее заданным (вместо Win32 API функции lstrcmpi можно использовать функцию SameText или CompareText из библиотеки SysUtils). Поскольку мы ищем дескриптор процесса для последующего наблюдения за его завершением, то необходимо включить разрешение доступа SYNCHRONIZE в параметры вызова функции OpenProcess. Если результат сравнения положительный, то возвращаем полученный дескриптор процесса, иначе освобождаем связанные с ним ресурсы с помощью вызова функции CloseHandle и переходим к очередному идентификатору процесса. Если же искомый процесс так и не будет найден среди списка запущенных процессов, наша функция поиска вернет значение 0, являющееся недопустимым в качестве дескриптора процесса.
Посмотрим теперь на код нити мониторинга целиком:
type
TMonitorThread = class(TThread)
private
FWindowtitle,
FWindowClass,
FAppName: string;
FTerminateWindow,
FRespawnApp: Boolean;
FWaitTimeout: Cardinal;
procedure SetTerminateWindow(Value: Boolean);
procedure SetRespawnApp(Value: Boolean);
protected
procedure Execute; override;
public
constructor Create;
// Заголовок окна, которое должно быть закрыто
property Windowtitle: string read FWindowtitle write FWindowtitle;
// Имя класса окна
property WindowClass: string read FWindowClass write FWindowClass;
// Путь к программе, которую необходимо перезапускать
property AppName: string read FAppName write FAppName;
// Закрывать ли указанное окно?
property TerminateWindow: Boolean read FTerminateWindow write SetTerminateWindow default False;
// Перезапускать ли указанную программу?
property RespawnApp: Boolean read FRespawnApp write SetRespawnApp default False;
// Время ожидания (в миллисекундах) в теле цикла нити
property WaitTimeout: Cardinal read FWaitTimeout write FWaitTimeout default 250;
end;
constructor TMonitorThread.Create;
begin
inherited Create(True); // Создаем новую нить приостановленной (Suspended)
Priority := tpIdle; // Исполнять нить только при простое ОС
FreeOnTerminate := True; // Автоматически освобождать память после завершения нити
FWindowtitle := '';
FWindowClass := '';
FAppName := '';
FTerminateWindow := False;
FRespawnApp := False;
FWaitTimeout := 250;
end;
procedure TMonitorThread.Execute;
var
ClsName,
WndName: PChar;
Process: THandle;
Wnd: HWND;
WaitResult: DWORD;
begin
if FTerminateWindow then // Подготавливаем параметры для принудительного закрытия окна
begin
if FWindowClass = '' then
ClsName := nil
else
ClsName := PChar(FWindowClass);
if FWindowtitle = '' then
WndName := nil
else
WndName := PChar(FWindowtitle);
end;
if FRespawnApp then // Находим в памяти исполняющийся процесс или запускаем новый
begin
Process := FindProcess(FAppName);
if Process = 0 then
Process := ExecProcess(FAppName);
end;
while not Terminated do
begin
if FTerminateWindow then
begin
Wnd := FindWindow(ClsName, WndName);
if IsWindow(Wnd) then // Если окно найдено, закрываем его
begin
SendMessage(Wnd, WM_CANCELMODE, 0, 0);
SendMessage(Wnd, WM_CLOSE, 0, 0);
end;
end;
if FRespawnApp then
begin
WaitResult := WaitForSingleObject(Process, FWaitTimeout);
if WaitResult = WAIT_OBJECT_0 then // Если обнаружено завершение процесса, перезапускаем его
begin
CloseHandle(Process); // Освобождаем дескриптор уже завершенного процесса
Process := FindProcess(FAppName);
if Process = 0 then
Process := ExecProcess(FAppName);
end;
end
else
Sleep(FWaitTimeout); // Пауза для случая, когда не требуется перезапускать процесс
end;
if FRespawnApp then
CloseHandle(Process); // Освобождаем дескриптор процесса
end;
procedure TMonitorThread.SetRespawnApp(Value: Boolean);
begin
FRespawnApp := Value and (FAppName <> '');
end;
procedure TMonitorThread.SetTerminateWindow(Value: Boolean);
begin
FTerminateWindow := Value and ((FWindowtitle <> '') or (FWindowClass <> ''));
end;
Как вы можете видеть, класс нити содержит ряд управляющих свойств (Windowtitle, WindowClass, AppName, TerminateWindow, RespawnApp, WaitTimeout), которые определяют поведение тела главного цикла нити (метода Execute). Значения по умолчанию для скалярных свойств класса, который не будет сохранен в ресурсах программы (т.е. не является наследником класса TComponent), не требуются (игнорируются компилятором), но помогают автору не забыть, что же он хотел им присвоить при создании класса. Загружая значения управляющих свойств из реестра (Registry), мы получаем возможность гибкого контроля над нитью. Замечу, что для уменьшения количества проверок параметров в теле главного цикла нити, сделано невозможным включение режима перезапуска приложения без указания его имени, а также активация режима уничтожения окна, если не заданы заголовок или имя класса окна (см. реализацию методов SetRespawnApp и SetTerminateWindow). Win32 API функция FindWindow позволяет в качестве одного из параметров (заголовок окна или имя класса окна) передать пустой указатель (nil), поэтому нельзя просто привести строковые переменные к типу PChar (PChar('') <> nil), а приходится копировать значения строк в переменные типа PChar. После завершения главного цикла нити в случае, если мы использовали дескриптор процесса для слежения за его завершением, освобождаем этот дескриптор вызовом функции CloseHandle.
Так как существует великое множество примеров использования иконок в трее, отсылаю любознательных к исходным кодам программы без дополнительных объяснений. Осталось поговорить об интерфейсе с пользователем. Взглянем на главную форму:

Обратите внимание на размещение компонента CheckBox поверх рамки GroupBox. Обработчик события OnClick CheckBox'а выглядит следующим образом:
procedure TMainForm.TerminateCheckBoxClick(Sender: TObject);
var
I: Integer;
begin
with Sender as TCheckBox do
begin
for I := 0 to Parent.ControlCount - 1 do
if (Parent.Controls[I] <> Sender) and (Parent.Controls[I] is TWinControl) then
Parent.Controls[I].Enabled := Checked;
end;
OKButton.Enabled := not ((TerminateCheckBox.Checked and
((titleComboBox.Text = '') and (ClassComboBox.Text = '')))
or (RespawnCheckBox.Checked and (AppNameEdit.Text = '')));
end;
Обработчик общий как для CheckBox "Terminate window", так и "Respawn application", поэтому используем в теле обработчика параметр Sender. Перебираем в цикле все дочерние элементы контейнера GroupBox (который доступен через свойство Parent). Если очередной элемент является наследником TWinControl, то активируем или деактивируем его (свойство Enabled) согласно состоянию CheckBox'а (не забудьте про проверку на совпадение с самим CheckBox'ом, а то второй раз вы его включить не сможете). Хорошо бы не позволить пользователю ввести неполный набор параметров, что и делает (запрещая кнопку диалога "OK") последняя строка тела обработчика (на первый взгляд - полный бред, но автор программы, будучи человеком непьющим, смеет утверждать, что при внимательном рассмотрении все становится понятно). Обработчик события OnChange компонентов выбора заголовка окна, имени класса окна и имени приложения для перезапуска также запрещает кнопку "OK" при ошибочном вводе пользователем параметров. Нажатие на кнопку "OK" приводит к сохранению введенных параметров в реестре (Registry) и скрывает форму, а кнопка "Cancel" только скрывает форму.
Вернемся к проблеме экономного расходования ресурсов компьютера. Вы не замечали (с ужасом глядя на данные из Task Manager), что любая даже самая простая программа (и не только написанная лично вами) занимает в бесценной оперативной памяти неприлично много места. Составленное на чистом Win32 API приложение, которое только и делает, что выводит на экран главное окно, занимает около 15 KB на диске и более 1 MB в памяти. Это же форменное БЕЗОБРАЗИЕ! Решить эту проблему не так сложно, как многие из вас могут полагать. Существует Win32 API функция SetProcessWorkingSetSize, которая позволяет регулировать минимальный и максимальный объемы памяти, которую менеджер виртуальной памяти Windows будет выделять для процесса. Если оба эти параметры равны -1 ($FFFFFFFF), вся распределенная виртуальная память процесса освобождается (PSAPI содержит функцию EmptyWorkingSet, выполняющую идентичные действия). Это совершенно не препятствует выполнению процесса, так как вся действительно необходимая память будет тут же выделена вновь, хотя данный метод и оказывает негативное влияние на производительность процесса (не применяйте его для процессов с интенсивными вычислениями). Win32 API функция GetCurrentProcess возвращает псевдо-дескриптор исполняемого процесса, поэтому не надо заботиться об освобождении этого дескриптора - он суть всего лишь специальная константа (т.е. вызов функции CloseHandle не требуется).
procedure TMainForm.AdjustWorkingSetSize; begin SetProcessWorkingSetSize(GetCurrentProcess, DWORD(-1), DWORD(-1)); end;
Вызовем этот метод в обработчике события OnCreate главной формы. Уже лучше, но почему после показа главной формы объем занимаемой памяти подрастает? Добавим вызов этого метода также в обработчик события OnHide главной формы, где заодно настроим контекстное меню программы на случай изменения ее параметров.
procedure TMainForm.FormHide(Sender: TObject); begin StartItem.Enabled := (FTerminateWindow and ((FWindowtitle <> '') or (FWindowClass <> ''))) or (FRespawnApp and (FAppName <> '')); AdjustWorkingSetSize; end;
Вот теперь программа занимает минимум оперативной памяти (для приложения, написанного с использованием VCL), потребляет минимум процессорного времени, и все еще продолжает работать.
Пришло время разобраться с самой иконкой в трее. Выглядит она так:

Контекстное меню позволяет показать главную форму для настройки параметров, запустить или остановить рабочую нить и завершить исполнение программы. Во время работы нити пункт "Setup" не активен, пункт "Start!" меняется на "Stop", а иконка Арни зажигает глаза недобрым красным светом. Продолжим экономить ресурсы? Если одну иконку (на которой Арни еще добрый) назначить как иконку приложения (Delphi меню Project|Options|Application|Icon), а вторую вы можете лицезреть на главной форме (в качестве Image), то именно их можно использовать для отображения в трее.
Чтобы нить мониторинга можно было стартовать автоматически при запуске программы (в случае правильной настройки необходимых параметров), добавляем в код обработчика события OnCreate главной формы проверку на специальный ключ в командной строке:
procedure TMainForm.FormCreate(Sender: TObject);
begin
...
LoadParams; // Загружаем параметры из реестра
if UpperCase(ParamStr(1)) = '/A' then
begin
if StartItem.Enabled then
Start;
end;
AdjustWorkingSetSize;
end;
Метод Start запускает нить мониторинга с необходимыми параметрами, а также меняет иконку в трее (на злобного Арни) и модифицирует контекстное меню. В противоположность ему, метод Stop завершает выполнение нити и возвращает в первоначальный вид иконку и контекстное меню (см. исходный код).
И последнее, на чем хотелось бы заострить ваше внимание - это код метода OnShow главной формы. Если вы заметили, на главной форме поля ввода "Window title to terminate" и "Window class to terminate" реализованы через компоненты ComboBox. Сделано это не случайно, а для большего удобства задания параметров. Первый ComboBox являет собой список заголовков всех видимых в данный момент окон верхнего уровня (т.е. дочерних к рабочему столу (Desktop)), а второй заполнен именами классов этих окон. Таким образом, чтобы настроить программу на подавление определенного окна, надо вывести это окно на экран, а затем выбрать из списка его заголовок и (или) имя класса окна (можно попытаться подобрать класс окна эмпирическим путем, так как его имя не всегда очевидно). Заполнить эти столь полезные в данном случае списки совсем несложно:
function EnumWindowsProc(hWnd: HWND; lParam: LPARAM): BOOL; stdcall;
var
Str: array[0 .. 255] of Char;
begin
if IsWindowVisible(hWnd) then // Окно видимое?
begin
GetWindowText(hWnd, Str, SizeOf(Str)); // Получаем заголовок окна
if Str[0] <> #0 then
// Если заголовок непустой, добавляем его в соответствующий список
TMainForm(lParam).titleComboBox.Items.Add(Str);
GetClassName(hWnd, Str, SizeOf(Str)); // Получаем имя класса окна
if Str[0] <> #0 then
// Если имя класса непустое, добавляем его в список имен классов
TMainForm(lParam).ClassComboBox.Items.Add(Str);
end;
Result := True; // Продолжаем процесс перебора до конца списка окон
end;
procedure TMainForm.FormShow(Sender: TObject);
begin
...
titleComboBox.Items.Clear; // Очищаем список заголовков окон
ClassComboBox.Items.Clear; // Очищаем список имен классов окон
EnumWindows(@EnumWindowsProc, LPARAM(Self)); // Вызываем перебор всех окон верхнего уровня,
// передав ссылку на главную форму в качестве параметра
...
end;
Win32 API функция EnumWindows вызывает переданную ей функцию с очередным дескриптором окна верхнего уровня. Так как функция обратного вызова (Callback) будет вызываться не программой, а кодом Windows, она должна быть объявлена как имеющая соглашение вызова stdcall, что влияет на передачу параметров через стек и очистку стека при возврате (Delphi стандартно использует соглашение fastcall). Последний параметр функции EnumWindows передается неизмененным в нашу функцию обратного вызова - воспользуемся этим для ссылки на члены класса главной формы. Сам код функции обратного вызова, надеюсь, не требует комментариев, замечу лишь, что если вернуть значения False в качестве результата, функция EnumWindows прекратит дальнейшие итерации по списку окон (это может быть полезно при поиске конкретного окна, чтобы не перебирать уже ненужные).
Изучение исходных кодов программы Terminator.zip (8.5K) позволит вам самим разобраться в том, что осталось за рамками повествования данной статьи (код написан под Delphi 7, поэтому, возможно, потребуются некоторые изменения под другие версии компилятора). Искренне надеюсь, что кому-то эти материалы окажутся полезными. При возникновении интересных вопросов и дополнений - пишите.
Copyright© 2003 Морозов Алексей Викторович (fox@europaplus.ru)
30 лет, MCSE, MCSD, Brainbench Delphi Professional, Brainbench C Professional
Специально для DelphiPlus
| 2011 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 2010 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 2009 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 2008 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 2007 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 2006 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 2005 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 2004 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 2003 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 2002 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 2001 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 2000 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 1999 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
- Услуги аутсорсинга в области программирования
- Как продлить срок службы картриджей
- Мошенничество во Всемирной Паутине. Осторожно: фишинг!
- Web-студия
- Как легально поднять уровень индекса цитирования.
- Мы реально сможем помочь вам в управлении предприятием
- Создание сайтов – популяризация вашего замысла
- Свой сайт. Управление ресурсом
- Семантическое ядро сайта или правила подбора ключевых фраз
- Инфо-Предприятие: выгоды явные и не явные
- Программирование в среде Delphi 8 for .NET
- Практикум по Delphi для решения прикладных задач
- Фундаментальные алгоритмы и структуры данных в Delphi
- Delphi 6. Программирование на Object Pascal
- Delphi и технология COM
- Delphi в шутку и всерьез: что умеют хакеры
- Программирование в Delphi глазами хакера
- Delphi 2005. Секреты программирования
- Искусство создания компонентов Delphi
- Приемы программирования в Delphi на основе VCL
- Программирование баз данных в Delphi 7
- Программирование баз данных в Delphi
- Программирование в среде Delphi
- Программирование в Delphi 7
- Язык SQL в Delphi 5