Right now do !

[C# 응용] Tray icon 응용프로그램 만들기

by 지금당장해

 필자는 윈도우즈 OS에서 동작하는 프로그램의 형태를 총 3가지로 나눈다. Windows(UI) 프로그램 / Windows Service 프로그램 / Web 응용프로그램으로 크게 분류한다. 같은 Windows 프로그램인데 Winform기반이나 WPF기반이냐 그도 아니면 MFC를 작성 했는가는 그저 구현 기술로 본다. *nix OS에서는 Windows Service와 같은 프로그램을 데몬(Demon)이라고 칭한다. Windows service 형태는 SQL Server나 IIS와 같이 사용자 인터페이스(UI)가 없이 이를 사용하는 Client프로그램에 서비스를 제공하는 프로그램이다. 

 

 필자가 오늘 논하고자 하는 형태는 Windows UI 프로그램이나 때로는 Service와 같이 UI없이 Background로 동작하고 있다가 특정 이벤트가 발생하면 UI가 Wackup하거나 그 마저도 없이 계속 Service만 하는 형태의 프로그램이다. 엄연히 Service프로그램이 있음에도 후자를 고려해야 하는이유는 이렇다. Service 프로그램은 고려 해야 할 것이 많다. 실행 권한도 일반 사용자가 아니라 권한에 관련된 고려사항도 있고 UI로 표현하는 것이 원천적으로 봉쇄되어 있어 시스템 이벤트나 Logging 파일로 사용자와 대화 아니 통보를 해야 한다. 

 우리가 요즘 많이 사용하는 카카오 톡의 PC버전에서 이러한 예를 볼 수 있다. 평소에는 창을 닫아 놨다가 내가 사용하게 싶을때 불러내거나 대화 상대에게 메시지를 보내면 서버를 통해 타 클라이언트에 메시지가 전달되고 이 이벤트에 의해 창이 표출된다. 화면은 닫혀 있고 프로세스는 살아 있는 것이다. 사용자가 닫혀있는 창을 불러내어 메시지를 입력 할 수 있는 진입점을 Windows의 Tray icon이라고 한다. (전통적으로 이렇게 불렀다. 이게 마이크로소프트의 공식 명칭인지는 필자도 모르겠다.) Windows 10의 경우 작업표시줄 우측에 이렇게 옹기종기 모여있다. 

 

Windows10의 Tray Icon

이 아이콘 위에서 마우스 오른쪽 버튼을 누르면 해당하는 프로그램의 기능을 사용할 수 있는 Context Menu가 나타난다. 닷넷으로도 이런 프로그램을 작성할 수 있다. 이번 편에서는 Tray icon으로 제어하는 간단한 프로그램을 작성하는 과정을 설명 하려 한다.

 

프로그램 주요 기능은 이렇다.

  1. N 모 포달사에서 제공하는 미니사전을 브라우저 컨트롤에 표출하는 창을 구현 한다.
  2. 브라우저 컨트롤은 얼마전에 소개한 크로미움 기반의 컨트롤을 이용한다.
  3. 창을 닫으면 프로그램이 종료되지 않고 남아 있도록 한다.
  4. 트레이 아이콘에서 컨텍스트 메뉴를 제공하며 창을 띠우거나 프로그램을 종료할 수 있다.
  5. 특정 단축키를 누르면 해당 프로그램 창이 나타난다.

Visual Studio에서 Winform 프로젝트를 하나 구성한다.

우리 업자들에게 Winform이라는 용어를 말하면 보통 잘떡같이 알았듯지만 최근에 닷넷을 접한 분들을 위해 좀더 친절히 안내을 하자면 이렇다. (Visual Studio 2017을 기준으로....)

 

파일>새로 만들기>새 프로젝트>설치됨 항목중 : Visual C# : 프로젝트 템플릿 중 Windows Forms 앱(.NET Framework)

 

프레임웍크는 크로미움 라이브러리인 CefSharp를 참조 해야 하기 때문에 4.5이상이어야 한다. 해서 필자는 4.6.2를 선택했다. 기본적으로 생성된 프로그램 구조를 그대로 이용하는데 Form1이런 식으로 MS에서 마음대로 지어 버린 Class이름을 의미 있는 이름으로 Rename하는 작업들을 살짝 한다.

 

WinForm 프로젝트 생성

 

일단 화면 설계는 이렇다.

화면 구성(설계)

필자가 작성한 프로그램중 UI구성이 가장 간단하다. 사실 프로그램 상단에 trackBar만 없으면 Panel도 필요 없는 구조다. trackBar를 이용해 창의 투명도를 정하는데 카카오 톡 같은 메신저 프로그램에서 프라이버시를 위해 그런 기능이 있는데 사전에 왠 프라이버시냐 하겠지만 필자는 필요하다. 사실 국어던 영어던 쓰려고 하면 철자가 긴가민가 할 때가 많은데 무슨 내용을 찾았는지 지나가는 동료들에게 들키고 싶지 않을때가 있다. (그만큼 찾아 보는 단어가 기초적이란 말이다.) 

이렇게 디자인되 창에 추가적으로 작업해야 할 내용은 이렇다.

 

  1. 창의 크기를 적절히 설정 한다. - Form 속성 Size를 450, 500으로 지정 (독자 맘대로 해도 된다.)
  2. 창의 시작 포지션을 지정 한다. - Form 속성 StartPosition을 CenterScreen으로 설정
  3. 창을 고정사이즈 툴 윈도우로 지정 한다. - Form 속성 FormBorderStyle을 FixedToolWindow으로 설정
  4. 창이 나타 났을때 작업 표시줄에 나타나지 않도록 한다. - Form 속성 ShowInTaskbar를 False로 설정 (안해도 그만이다. 필자는 이런류의 윈도우는 작업 표시줄에 없어야 한다고 본다.)
  5. TrackBar를 창에 상단에 Docking한다. - trackBar 속성 Dock을 Top으로 설정
  6. Panel을 나머지 창 전체에 Docking한다 - panel 속성 Dock을 Fill로 설정

오늘의 주인공 NotifyIcon 추가

 지금까지 용어를 Tray Icon이라고 했는데 왜 그런지 모르겠지만 닷넷 세상에서는 이것을 Notify Icon이라고 부른다. 역시 Visual Studio의 '도구 상자'에서 추가 하는편이 제일 쉬운데 이를 선택하고 화면에 가져다 놓아보면 화면(Form) 어딘가에 자리를 차지 하는것이 아니라 디자인 창 하단에 올라간다.  그리고 컨텍스트 메뉴(ContextMenuStrip)도 하나 돌려야 한다. 이 메뉴도 마찬가지다. 화면 하단 컴포넌트 영역에 올라간다. 이 영역은 UI가 없는 '구성 요소'를 올려 놓을때 표출되는 영역이다. Notify Icon이나 이를 위한 Context Menu가 Form의 영역(좌표와 크기를 말함)과 전혀 관계 없지 않는가? 

Notify Icon과 Context Menu 배치

그리고 Context 메뉴를 편집 하자. 디자인 화면 하단에 contextMenuTray를 클릭하면 아래 화면과 같이 메뉴 편집이 가능해진다.

 

 

Context Menu 편집

여기에 입력란에 추가할 메뉴 캡션을 입력한다. 그리고 각 메뉴 항목을 의미 있는 이름으로 Rename한다. 필자는 위 그림과 같이 프로그램을 종료 시키고 사전 창을 활성화 할 수 있는 메뉴 아이템을 구성하였다. 그리고 이 메뉴를 Notify Icon과 연계해준다. 아래 그림과 같이 속성 창에서 ContextMenuStrip 항목을 클릭하여 준비해둔 Context Menu를 설정한다.

 

Notify Icon에 Context Menu설정

이제 코딩을 해보자!

본격적인 코딩에 앞서 이 프로젝트에서 사용할 CefSharp을 NuGet을 통해 참조하자. 이 방법에 대해서는 일전에 관련 글을 작성해 두었으니 못 본 독자는 잠시 하차하여 이 내용부터 확인하고 다시 돌아오는 편이 좋을 듯 하다. (아래 링크 참고)

[C#응용] 난 (이제) IE컨트롤이 싫어요.

Step1> Browser control 생성 및 Docking

using CefSharp;
using CefSharp.WinForms;
// ..중략

private ChromiumWebBrowser _webBrowser;

private void DicForm_Load(object sender, EventArgs e)
{
    // app.config에 정의된 브라우징 대상 URL
    string browseUrl = ConfigurationManager.AppSettings["browseUrl"];
    _webBrowser = new ChromiumWebBrowser(browseUrl);
    // 자바스크립트 오류가 표출되지 않도록 처리
    JsDialogHandler jss = new JsDialogHandler();
    _webBrowser.JsDialogHandler = jss;
    //panel에 브라우저 컨트롤 도킹
    _webBrowser.Dock = DockStyle.Fill;
    this.panel.Controls.Add(_webBrowser);
    // .. 이하 생략

위 코드에서 this.panel은 화면구성 단계에서 윈도우 전체에 대부분을 차지하는 영역으로 디자인 해둔것을 기억 할 것이다. 여기에 Dock 속성으로 Fill(가득 체움)으로 지정하여 panel의 Controls에 Add하였다. 만약 대상이 되는 Web page가 가변 사이즈에 따라 유연하게 디자인이 되어 있다면 이렇게 하므로써 창 Re-size에 따른 브라우저 컨트롤의 Re-size 기능이 별도로 필요 없이 구현된 것이다. Docking에 대해 더 자세히 학습이 필요한 독자는 아래 마이크로소프트 문서를 참고 하기 바란다.

https://docs.microsoft.com/ko-kr/dotnet/api/system.windows.forms.control.dock?f1url=https%3A%2F%2Fmsdn.microsoft.com%2Fquery%2Fdev15.query%3FappId%3DDev15IDEF1%26l%3DKO-KR%26k%3Dk(System.Windows.Forms.Control.Dock);k(TargetFrameworkMoniker-.NETFramework,Version%3Dv4.6.2);k(DevLang-csharp)%26rd%3Dtrue&view=netframework-4.8

 

Control.Dock Property (System.Windows.Forms)

--> Control.Dock Control.Dock Control.Dock Control.Dock Property 정의 이 문서의 내용 어느 컨트롤 테두리가 부모 컨트롤에 도킹되는지를 가져오거나 설정하고 해당 부모를 기초로 컨트롤 크기를 조정하는 방법을 결정합니다.Gets or sets which control borders are docked to its parent control and determines how a control is resized

docs.microsoft.com

Step2> 창은 닫히나 프로그램을 살아 있도록 구현

통상 화면 우측 상단에 X표를 누르면 프로그램이 종료된다고 생각한다. 그런데 지금 요구사항은 화면은 닫되 프로그램은 살아 있어서 언제든지 메뉴나 단축키를 통해 불러 낼 수 있어야 한다. Winform개발자 입장에서 X표를 누르는 사용자 행위가 2번의 이벤트로 나누어 오는것을 잘 이용하여 처리하면 된다. 하나는 FormClosing 이벤트이며 이는 "창을 닫으려고 하는데요"로 이해 하면 쉽다. 사용자는 X표를 눌렀고 창은 아직 닫히지 않았다. 여기서 아래 코드와 같이 인자 e의 속성 Cancel을 true로 주면 창이 닫히지 않고 true로 주면 창이 진짜 딛힌다. (이 창은 이 프로그램의 메인 창이므로 창이 닫히면 프로그램이 종료된다.) 

 

private void DicForm_FormClosing(object sender, FormClosingEventArgs e)
{
    if (_systemShutdown) // 트레이 아이콘의 컨텍스트 메뉴를 통해 프로그램 종료가 선택된경우 true
    {
        e.Cancel = false;
    }
    else
    {
        e.Cancel = true;
        this.Visible = false; // 화면을 닫지 않고 단지 숨길 뿐이다.
    }
}

Step3> 컨텍스트 메뉴 멤버 클릭 이벤트 구현

디자인 화면으로 잠시 돌아가서 해당 이벤트 멤버를 더블 클릭하여 이벤트 처리함수를 자동 구현하자. 물론 개발자가 FormLoad이벤트 함수에 추가해도 되지만 이런 코드는 가급적 OOO.Designer.cs 파일로 숨기고자 하는 마이크로소프트의 의도를 그대로 받아 들이자.

Context메뉴에 ItemClicked 이벤트 처리기 생성

이렇게 생성된 함수 원형에 아래와 같은 내용을 넣는다. 단, 아래 메뉴 아이템별로 분기하는 문자이 보이는데 이는 각 메뉴 아이템인 "프로그램 종료", "사전 창 활성화" 각 항목에 Tag에 이와 같은 문자열을 넣어 주어야 한다. 다른 방법으로 메뉴 아이템을 구별해도 된다. 하지만 좀더 명시적이고 가독성이 확보되는 방법을로 필자가 고안한 방법이다.

각 메뉴아이템에 Tag속성에 구분 문자열 입력

private void contextMenuTray_ItemClicked(object sender, ToolStripItemClickedEventArgs e)
{
    if (e.ClickedItem.Tag != null && e.ClickedItem.Tag.ToString().Equals("EXIT"))
    {
        UnregisterHotKey(this);
        this.Close();
        this.Dispose();
        Properties.Settings.Default.Save();
        Application.Exit();
    }
    else if (e.ClickedItem.Tag != null && e.ClickedItem.Tag.ToString().Equals("DIC"))
    {
        this.Visible = true;
    }            
}

Step4> Windows API선언 및 상수 정의

public static int MOD_ALT = 0x1;
public static int MOD_CONTROL = 0x2;
public static int MOD_SHIFT = 0x4;
public static int MOD_WIN = 0x8;
public static int WM_HOTKEY = 0x312;
public static int WM_QUERYENDSESSION = 0x0011;

[DllImport("user32.dll")]
private static extern bool RegisterHotKey(IntPtr hWnd, int id, int fsModifiers, int vlc);
[DllImport("user32.dll")]
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);

단축키와 프로그램 종료 이벤트 처리를 위해 Windows API와 그와 관련된 상수를 정의 한다. Winform은 태생이 전통적인 Windows를 기반으로 한다. 따라서 불행인지 다행인지 모르겠지만 Windows API를 Import해서 사용할 수 있다. 이와 관련된 좀더 상세한 내용은 별도로 다루도록 하겠다.

Step5> 단축키 등록 및 해제

Windows시스템에게 나는 이런 단축키를 상용 할 것이니 이런 키가 들어오면 나한테 알려줘 하는 의미의 등록이다. 반대로 프로그램이 종료 될 때는 이 반납을 해야 한다. 등록하는 과정은 아래 코드와 같인 Form Load함수에서 수행한다. 해제 하는 과정은 이미 위 코드에서도 등장 했는데 프로그램이 종료되는 시점에 불려지면 된다.

 

public static void RegisterHotKey(Form f, Keys key)
{
    int modifiers = 0;
    if ((key & Keys.Alt) == Keys.Alt)
        modifiers = modifiers | MOD_ALT;
    if ((key & Keys.Control) == Keys.Control)
        modifiers = modifiers | MOD_CONTROL;
    if ((key & Keys.Shift) == Keys.Shift)
        modifiers = modifiers | MOD_SHIFT;
    Keys k = key & ~Keys.Control & ~Keys.Shift & ~Keys.Alt;
    
    keyId = f.GetHashCode(); 
    RegisterHotKey((IntPtr)f.Handle, keyId, (int)modifiers, (int)k);
}


public static void UnregisterHotKey(Form f)
{
    try
    {
      UnregisterHotKey(f.Handle, keyId);
    }
    catch (Exception ex)
    {
      MessageBox.Show(ex.ToString());
    }
}

private void DicForm_Load(object sender, EventArgs e)
{
    // ctrl + shift + D 키를 핫 키로 등록한다.
    Keys k = Keys.D | Keys.Control | Keys.Shift;
    RegisterHotKey(this, k);

    // .. 중략

Step6> 단축키 등록 등 윈도우 메시지 처리

WndProc함수를 override하여 핫키 메시지를 거르는 기능을 구현한다. 우리가 윗 단계에서 등록한 ctrl + shift + D키가 눌린 경우 숨긴 창을 나타내고 창에 포커스를 준다. 

protected override void WndProc(ref System.Windows.Forms.Message m)
{
    if (m.Msg == WM_QUERYENDSESSION)
    {
        _systemShutdown = true;        
    }
    else if (m.Msg == WM_HOTKEY)
    {
        int keyCode = HIWORD(m.LParam);
        if ((MOD_CONTROL | MOD_SHIFT) == LOWORD(m.LParam))
        {
            if ((int)Keys.D == keyCode)
            {
                this.Visible = true;
                this.TopMost = true;
                this.TopMost = false;
                this.Focus();
            }
        }
    }
    base.WndProc(ref m);
}

private static int LOWORD(IntPtr param)
{
    uint uLParam = unchecked(IntPtr.Size == 8 ? (uint)param.ToInt64() : (uint)param.ToInt32());
    int nLowValue = unchecked((short)uLParam);
    return nLowValue;
}

private static int HIWORD(IntPtr param)
{
    uint uWParam = unchecked(IntPtr.Size == 8 ? (uint)param.ToInt64() : (uint)param.ToInt32());
    int nHighValue = unchecked((short)(uWParam >> 16));
    return nHighValue;
}

LOWORD, HIWORD 함수는 이벤트와 같이 넘어온 LONG파라미터를 SHIFT연산하여 로우, 하이 워드 값을 구하는 기능을 수행한다. 

 

이번 글에서는 관련하여 주요 코드위주로 다루었고 Winform을 최근 접한 독자는 원하는 기능을 구현하기에 부족 할 수 있다고 생각한다. 정리가 되는대로 소스 전부를 공개할 생각이다. (대단 하지는 않지만 ㅎㅎ)

블로그의 정보

지금 당장 해!!!

지금당장해

활동하기