2022/01/24

Electron Bookmarker

這是 Electron in Action chap2 的 github sample bookmarker github,該章節是第一個 Electron applicaiton sample,裡面使用了 localStorage 及 shell,因為測試的關係,修改使用 IPC,將 shell 呼叫移到 main process。

Electron API 有列出 API 項目,以及該 API 可以使用在 Main/Renderer process 的資訊

package.json

  • 將 electron 版本改為目前的 stable 版
    • "electron": "^13.1.9"
{
  "name": "bookmarker",
  "version": "1.0.0",
  "description": "An example Electron application",
  "main": "app/main.js",
  "scripts": {
    "start": "electron .",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+ssh://git@github.com/electron-in-action/bookmarker.git"
  },
  "author": "Steve Kinney <hello@stevekinney.net> (http://stevekinney.net)",
  "license": "MIT",
  "private": true,
  "dependencies": {},
  "bugs": {
    "url": "https://github.com/electron-in-action/bookmarker/issues"
  },
  "homepage": "https://github.com/electron-in-action/bookmarker#readme",
  "devDependencies": {
    "electron": "^13.1.9"
  }
}

main.js

  • 把原本放在 renderer process 的 shell 呼叫,移到 main process

  • 增加 IPC main 的部分,處理 renderer process 傳送的訊息及 href 參數,然後呼叫 shell.openExternal(href);

  • 因為新版的 electron 的限制,renderer process 使用了 localStorage,故 BrowserWindow 必須增加兩個 webPreferences 設定

    mainWindow = new BrowserWindow({
          webPreferences: {
            nodeIntegration: true,
            contextIsolation: false
          }
      });
  • 用以下 code 查詢 localStorage 儲存的硬碟路徑

    // 查詢 application 將 localStorage 儲存在哪一個 folder
      let userdata = app.getPath('userData');
      console.log('userdata='+userdata);
const {
  app,
  shell,
  BrowserWindow,
  ipcMain
} = require('electron');

let mainWindow = null; // #A

app.on('ready', () => {
  console.log('Hello from Electron.');

  // 查詢 application 將 localStorage 儲存在哪一個 folder
  let userdata = app.getPath('userData');
  console.log('userdata='+userdata);

  mainWindow = new BrowserWindow({
      webPreferences: {
        nodeIntegration: true,
        contextIsolation: false
      }
  });
  mainWindow.webContents.loadURL(`file://${__dirname}/index.html`); // #A
});

ipcMain.on('shell-openExternal', (event, data) => {
  var href = data.href;
  console.log("shell-openExternal href="+href);
  shell.openExternal(href);
});

index.html

  • 修改 include js 的部分

    <!--     <script>
          require('./renderer');
        </script> -->
        <script src="renderer.js"></script>
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link rel="stylesheet" href="style.css" type="text/css">
    <title>Bookmarker</title>
  </head>
  <body>

    <h1>Bookmarker</h1>

    <div class="error-message"></div>

    <section class="add-new-link">
      <form class="new-link-form">
        <input type="url" class="new-link-url" placeholder="URL" required>
        <input type="submit" class="new-link-submit" value="Submit" disabled>
      </form>
    </section>

    <section class="links"></section>

    <section class="controls">
      <button class="clear-storage">Clear Storage</button>
    </section>

<!--     <script>
      require('./renderer');
    </script> -->
    <script src="renderer.js"></script>

  </body>

</html>

style.css

html {
  box-sizing: border-box;
}

*, *:before, *:after {
  box-sizing: inherit;
}

body, input {
  font: menu; // #A
}

renderer.js

  • 刪除 shell 呼叫的部分,改用 IPC 傳送到 main process 處理

  • 注意處理 Submit 的寫法

    • 在填寫 newLinkUrl 時,用 validity 檢查是否為正確的 URL 字串,然後將結果,轉為指定 newLinkSubmit enable/disable

    • submit form 的時候,event.preventDefault(); 可避免 Chromium 呼叫原本 form submit 的動作,不會 trigger HTTP request

    • fetch 可取得 URL 的 response,在這邊只需要 title,用來作為 bookmark 的 title。實際上是用了 DOMParser parsing HTTP response data

      newLinkUrl.addEventListener('keyup', () => {
      newLinkSubmit.disabled = !newLinkUrl.validity.valid;
      });
      
      newLinkForm.addEventListener('submit', (event) => {
      event.preventDefault();
      
      const url = newLinkUrl.value;
      
      fetch(url)
        .then(response => response.text())
        .then(parseResponse)
        .then(findTitle)
        .then(title => storeLink(title, url))
        .then(clearForm)
        .then(renderLinks)
        .catch(error => handleError(error, url));
      });
  • localStorage 是 key-value 的形式

    localStorage.setItem(url, JSON.stringify({ title: title, url: url }));
let ipcRenderer = require('electron').ipcRenderer;

// const {shell} = require('electron');

const parser = new DOMParser();

const linksSection = document.querySelector('.links');
const errorMessage = document.querySelector('.error-message');
const newLinkForm = document.querySelector('.new-link-form');
const newLinkUrl = document.querySelector('.new-link-url');
const newLinkSubmit = document.querySelector('.new-link-submit');
const clearStorageButton = document.querySelector('.clear-storage');

newLinkUrl.addEventListener('keyup', () => {
  newLinkSubmit.disabled = !newLinkUrl.validity.valid;
});

newLinkForm.addEventListener('submit', (event) => {
  event.preventDefault();

  const url = newLinkUrl.value;

  fetch(url)
    .then(response => response.text())
    .then(parseResponse)
    .then(findTitle)
    .then(title => storeLink(title, url))
    .then(clearForm)
    .then(renderLinks)
    .catch(error => handleError(error, url));
});

clearStorageButton.addEventListener('click', () => {
  localStorage.clear();
  linksSection.innerHTML = '';
});

linksSection.addEventListener('click', (event) => {
  if (event.target.href) {
    event.preventDefault();
    var data = {
      href: event.target.href
    };
    // shell.openExternal(event.target.href);
    ipcRenderer.send('shell-openExternal', data);

  }
});


const clearForm = () => {
  newLinkUrl.value = null;
}

const parseResponse = (text) => {
  return parser.parseFromString(text, 'text/html');
}

const findTitle = (nodes) => {
  return nodes.querySelector('title').innerText;
}

const storeLink = (title, url) => {
  localStorage.setItem(url, JSON.stringify({ title: title, url: url }));
}

const getLinks = () => {
  return Object.keys(localStorage)
               .map(key => JSON.parse(localStorage.getItem(key)));
}

const convertToElement = (link) => {
  return `<div class="link"><h3>${link.title}</h3>
          <p><a href="${link.url}">${link.url}</a></p></div>`;
}

const renderLinks = () => {
  const linkElements = getLinks().map(convertToElement).join('');
  linksSection.innerHTML = linkElements;
}

const handleError = (error, url) => {
  errorMessage.innerHTML = `
    There was an issue adding "${url}": ${error.message}
  `.trim();
  setTimeout(() => errorMessage.innerText = null, 5000);
}

const validateResponse = (response) => {
  if (response.ok) { return response; }
  throw new Error(`Status code of ${response.status} ${response.statusText}`);
}

renderLinks();

執行畫面

References

Electron in Action

Electron in Action repositories

Electron in Action source code

require is not defined

Where an electron application's sessionStorage and localStorage stored?

electron-localStorage,如何在主进程和渲染进程中使用?

2022/01/17

Electron Clock

Electron Clock 跟 Alarm 的 tutorial

建立專案

mkdir electron-alarm-clock && cd electron-alarm-clock
npm init -y

安裝 electron,會安裝到 electron-alarm-clock/node_modules 裡面

npm install --save--dev electron

修改 package.json,"main" 及 "scripts" 的部分

{
  "name": "electron-alarm-clock",
  "version": "1.0.0",
  "description": "",
  "main": "app/main.js",
  "scripts": {
    "start": "electron .",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "electron": "^13.1.9"
  }
}

建立 main.js

const {
    app,
    BrowserWindow
} = require('electron')

const path = require('path')
const url = require('url')

app.on('ready', createWindow)

app.on('window-all-closed', () => {
    // darwin = MacOS
    if (process.platform !== 'darwin') {
       return false;
    }
    app.quit()
})

app.on('activate', () => {
    if (win === null) {
        createWindow()
    }
})

function createWindow() {
    // Create the browser window.
    win = new BrowserWindow({
        width: 400,
        height: 400,
        maximizable: false,
        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false
        }
    })

    win.loadURL(url.format({
        pathname: path.join(__dirname, 'index.html'),
        protocol: 'file:',
        slashes: true
    }))

    // Open DevTools.
    // win.webContents.openDevTools()

    // When Window Close.
    win.on('closed', () => {
        win = null
    })

    // When Window Minimize
    win.on('minimize', () => {
        win.hide()
    })

}

index.html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Electron</title>
</head>

<body>
    <h1>Hello Electron</h1>
    <p>Node Version:
        <script>document.write(process.versions.node)</script>
    </p>
    <p>Chrome Version:
        <script>document.write(process.versions.chrome)</script>
    </p>
    <p>Electron Version:
        <script>document.write(process.versions.electron)</script>
    </p>
</body>

</html>

啟動

npm start


Clock

安裝 moment 套件

npm install --save moment

修改 index.html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Electron</title>
</head>

<body>
    <h1>Hello Electron</h1>
    <p>Node Version:
        <script>document.write(process.versions.node)</script>
    </p>
    <p>Chrome Version:
        <script>document.write(process.versions.chrome)</script>
    </p>
    <p>Electron Version:
        <script>document.write(process.versions.electron)</script>
    </p>
    <hr>
    <div class="now-time"></div>
    <input type="text" class="alarm-time">

    <script src="app.js"></script>
</body>

</html>

新增 app.js

const moment = require('moment')

const elNow = document.querySelector('.now-time')
const elAlarm = document.querySelector('.alarm-time')
elAlarm.addEventListener('change', onAlarmTextChange)

let time = moment()

// 目前的時間
let nowTime
// alarm 時間,預設為目前的時間 + 5 seconds
let alarmTime

/** Set Time */
const now = moment(time).format('HH:mm:ss')
nowTime = now
elNow.innerText = now

const alarm = moment(time).add(5, 'seconds').format('HH:mm:ss')
alarmTime = alarm
elAlarm.value = alarm

timer()

/** Now Time */
function timer() {
    time = moment().format('HH:mm:ss')

    /** Set Now */
    nowTime = time
    elNow.innerText = time

    setTimeout(() => {
        timer()
    }, 1000)
}

/**
 * Save To Global Variable,
 * Can't Read Dom In Minimize Status.
 * @param {event} event
 */
function onAlarmTextChange(event) {
    alarmTime = event.target.value
}

啟動後,會看到畫面上增加 clock 部分,時間會一直不斷地更新

Alarm

安裝 node-notifier 套件

npm install --save node-notifier

修改 app.js

const notifier = require('node-notifier')
const path = require('path')

const moment = require('moment')

const elNow = document.querySelector('.now-time')
const elAlarm = document.querySelector('.alarm-time')
elAlarm.addEventListener('change', onAlarmTextChange)

let time = moment()

// 目前的時間
let nowTime
// alarm 時間,預設為目前的時間 + 5 seconds
let alarmTime

/** Set Time */
const now = moment(time).format('HH:mm:ss')
nowTime = now
elNow.innerText = now

const alarm = moment(time).add(5, 'seconds').format('HH:mm:ss')
alarmTime = alarm
elAlarm.value = alarm

timer()

/** Now Time */
function timer() {
    time = moment().format('HH:mm:ss')

    /** Set Now */
    nowTime = time
    elNow.innerText = time

    check()

    setTimeout(() => {
        timer()
    }, 1000)
}

/** Check Time */
function check() {
    const diff = moment(nowTime, 'HH:mm:ss').diff(moment(alarmTime, 'HH:mm:ss'))
    if (diff === 0) {
        //alert('wake up!')
        const msg = "It's" + alarmTime + ". Wake Up!"
        /** const msg = `It's ${alarmTime}. Wake Up!` */
        notice(msg)
    }
}

/**
 * System Notification
 * @param {string} msg
 */
function notice(msg) {

    /** https://github.com/mikaelbr/node-notifier */
    notifier.notify({
        title: 'Alarm Clock',
        message: msg,
        icon: path.join(__dirname, 'clock.ico'),
        sound: true,
    })
}

/**
 * Save To Global Variable,
 * Can't Read Dom In Minimize Status.
 * @param {event} event
 */
function onAlarmTextChange(event) {
    alarmTime = event.target.value
}

Tray

讓 application 能夠隱藏到系統列

修改 main.js

const {
    app,
    BrowserWindow,
    Tray,
    Menu,
} = require('electron')
function createWindow() {
    // ........ skipped

    // When Window Close.
    win.on('closed', () => {
        win = null
    })

    // Create Tray
    createTray()

}

// 這邊是 windows 的寫法, mac 沒有出現這兩個選單
function createTray() {
    let appIcon = null
    const iconPath = path.join(__dirname, 'clock.ico')

    const contextMenu = Menu.buildFromTemplate([{
            label: 'AlarmClock',
            click() {
                win.show()
            }
        },
        {
            label: 'Quit',
            click() {
                win.removeAllListeners('close')
                win.close()
            }
        }
    ]);

    appIcon = new Tray(iconPath)
    appIcon.setToolTip('Alarm Clock')
    appIcon.setContextMenu(contextMenu)

}

IPC

透過 IPC,能夠從 renderer process 呼叫 main process,重新將 application 顯示在前景

修改 app.js

let ipcRenderer = require('electron').ipcRenderer;


/**
 * System Notification
 * @param {string} msg
 */
function notice(msg) {

    /** https://github.com/mikaelbr/node-notifier */
    notifier.notify({
        title: 'Alarm Clock',
        message: msg,
        icon: path.join(__dirname, 'clock.ico'),
        sound: true,
    })

    /** Show Application */
    ipcRenderer.send('show-main-window');
}

利用 ipcRenderer.send('show-main-window'); 發送 "show-main-window" 給 main process

修改 main.js

const {
    app,
    BrowserWindow,
    Tray,
    Menu,
    ipcMain
} = require('electron')

//> ipcMain is ipc of main process
//> ipcMain listen to show-main-window channel here
ipcMain.on('show-main-window', () => {
    console.log('show-main-window by ipc');
    win.show();
});

當程式啟動後,把 application 縮小到背景,alarm 時間到了的時候,就會透過 IPC 將 application window 顯示到前景,同時產生一個系統的 notification

References

Electron - 新手入門 - 做一個鬧鐘吧

[Electron] IPC 機制

2022/01/10

nvm

nvm 是 Node.js 的版本管理器,可在同一台主機上安裝多個版本的 Node.js 環境,因為不同專案可能會使用不同的 NodeJS 版本,nvm 可讓不同版本的 NodeJS 並存,且可以動態因應專案而切換。

安裝

nvm 是一個 shell script,不是執行檔,安裝也直接用 curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash 安裝即可

~$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 14926  100 14926    0     0  37258      0 --:--:-- --:--:-- --:--:-- 38370
=> Downloading nvm from git to '/Users/charley/.nvm'
=> 正複製到 '/Users/charley/.nvm'...
remote: Enumerating objects: 347, done.
remote: Counting objects: 100% (347/347), done.
remote: Compressing objects: 100% (295/295), done.
remote: Total 347 (delta 39), reused 161 (delta 27), pack-reused 0
接收物件中: 100% (347/347), 196.36 KiB | 486.00 KiB/s, 完成.
處理 delta 中: 100% (39/39), 完成.
* (開頭指標分離於 FETCH_HEAD)
  master
=> Compressing and cleaning up git repository

=> Appending nvm source string to /Users/charley/.bash_profile
=> Appending bash_completion source string to /Users/charley/.bash_profile
=> Close and reopen your terminal to start using nvm or run the following to use it now:

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion

nvm 會安裝到 /Users/user/.nvm 使用者的 ~/.nvm 目錄中,同時將以下的環境變數增加到 profile 設定 ( ~/.bash_profile~/.zshrc~/.profile~/.bashrc )

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion

安裝後,可用以下指令確認安裝完成

command -v nvm

常用指令

版本號碼

$ nvm --version
0.38.0

ls

list 目前已經安裝的 nodejs

$ nvm ls
->     v14.17.5
default -> v14.17.5
iojs -> N/A (default)
unstable -> N/A (default)
node -> stable (-> v14.17.5) (default)
stable -> 14.17 (-> v14.17.5) (default)
lts/* -> lts/fermium (-> v14.17.5)
lts/argon -> v4.9.1 (-> N/A)
lts/boron -> v6.17.1 (-> N/A)
lts/carbon -> v8.17.0 (-> N/A)
lts/dubnium -> v10.24.1 (-> N/A)
lts/erbium -> v12.22.5 (-> N/A)
lts/fermium -> v14.17.5

list 遠端可安裝的 NodeJS 版本

nvm ls-remote

list 遠端可安裝的 NodeJS 版本,且限制 LTS 版本

nvm ls-remote --lts

install

安裝 NodeJS

nvm install v14.17.5

use

切換版本

nvm use v14.17.5

current

查詢目前版本

nvm current

run, exec

執行 NodeJS

nvm run node

執行特定版本的 node

nvm exec 12.8.1 node

which

查閱 nodejs 安裝路徑

$ nvm which v14.17.5
/Users/charley/.nvm/versions/node/v14.17.5/bin/node

alias

$ nvm alias
default -> v14.17.5
iojs -> N/A (default)
unstable -> N/A (default)
node -> stable (-> v14.17.5) (default)
stable -> 14.17 (-> v14.17.5) (default)
lts/* -> lts/fermium (-> v14.17.5)
lts/argon -> v4.9.1 (-> N/A)
lts/boron -> v6.17.1 (-> N/A)
lts/carbon -> v8.17.0 (-> N/A)
lts/dubnium -> v10.24.1 (-> N/A)
lts/erbium -> v12.22.5 (-> N/A)
lts/fermium -> v14.17.5

可以設定別名

nvm alias erbium v14.17.5

修改 default 版本

nvm alias default v14.17.5

npm

config

$ npm config ls
; cli configs
metrics-registry = "https://registry.npmjs.org/"
scope = ""
user-agent = "npm/6.14.14 node/v14.17.5 darwin x64"

; project config /Users/charley/.nvm/.npmrc
package-lock = false

; node bin location = /Users/charley/.nvm/versions/node/v14.17.5/bin/node
; cwd = /Users/charley/.nvm
; HOME = /Users/charley
; "npm config ls -l" to show all defaults.

list 所有設定

npm config ls -l

可修改 prefix, cache 路徑

npm config set prefix "/Users/charley/.nvm/versions/node/v14.17.5"
npm config set cache "/Users/charley/.npm"

安裝 npm 套件

npm install requirejs -g
npm install uglify-js -g
npm install less -g

套件會安裝到這個目錄

~/.nvm/versions/node/v14.17.5/lib/node_module

切換 NodeJS 版本時, npm library 也要重新安裝

.nvmrc

可在專案目錄中,建立一個 .nvmrc 檔案,裡面記錄 NodeJS 版本號碼

echo "14.17.5" > .nvmrc

echo "lts/*" > .nvmrc # to default to the latest LTS version

echo "node" > .nvmrc # to default to the latest version

切換到專案後,可以用 use 指令,直接切換專案的 NodeJS 版本

nvm use

nvm github 有記錄如何切換目錄後,自動切換 node 版本的 script,但目前覺得不需要那麼自動

References

Mac 安裝 NVM

macOS安裝與移除NVM

Node.js 環境設定-for mac

nvm:安裝、切換不同 Node.js 版本的管理器

How to install Node.js and npm on macOS

NVM、NPM、Node.js的安裝選擇