快速移除 Mac 中的 pkg 程式! - 用 Python 實現

在 mac 安裝程式最討厭看到 .pkg 的安裝檔,因為大部分都不提供簡易的移除方法,只能手動下指令一一刪除。這篇分享如何將指令用 python 包裝成簡單的 script(趕時間的話可下載立即使用!),以及每個指令背後的意義。

滿腦只想趕快刪除的朋友可以透過 github 下載原始碼直接使用:

原始碼下載:github

並到「如何使用寫好的 python script」章節了解如何使用。

如何透過 shell 下指令刪除 pkg?

Step 1:查詢套件全名

首先查詢套件的全名,透過可能的套件名稱關鍵字來過濾

$ pkgutil --pkgs | grep <套件可能的名稱>

假如回傳為空,則將套件全部列出透過肉眼尋找

$ pkgutil --pkgs

舉例來說,我想要刪除銀行的套件,透過套件安裝檔名「FBWS_Setup.pkg」猜測會有關鍵字「FBWS」,因此嘗試搜尋

$ pkgutil --pkgs | grep FBWS

shell 會回應

tw.FirstBank.FBWS.第一銀行網銀安控程式.FBWS_data.pkg
tw.FirstBank.FBWS.第一銀行網銀安控程式.nspr.pkg
tw.FirstBank.FBWS.第一銀行網銀安控程式.nss.pkg
tw.FirstBank.FBWS.第一銀行網銀安控程式.FBWS.pkg
tw.FirstBank.FBWS.第一銀行網銀安控程式.FBWS_Plugins.pkg
tw.FirstBank.FBWS.第一銀行網銀安控程式.com.hitrust.startFBWS.pkg

發現這些就是接下來要刪除的 pkg。

此時覺得很崩潰,只透過一個 pkg 檔安裝套件,他卻偷偷幫你裝了六個套件!因此刪除動作總共要做六次!官方竟然還不做刪除的方法,實在是….

所以寫了 python script 以備未來之需。但我們還是先把指令的方式走完。

Step 2:查詢套件的根目錄位置

找到完整套件名稱後,用以下指令查詢套件的基本訊息

$ pkgutil --pkg-info <完整套件名稱>

舉例來說,查詢範例中第一個想要刪除的 pkg

$ pkgutil --pkg-info tw.FirstBank.FBWS.第一銀行網銀安控程式.FBWS_data.pkg

shell 回應

package-id: tw.FirstBank.FBWS.第一銀行網銀安控程式.FBWS_data.pkg
version: 1.1802.04.16
volume: /
location: Library
install-time: 1643377765

得知套件的檔案都放在 /Library 下

Step 3:切換目錄開始移除套件

由上一步知道套件根目錄在 /Library ,因此先切換過去

$ cd /Library

開始移除檔案,這邊需要 superuser 權限

$ sudo pkgutil --only-files --files <完整套件名稱> | tr '\n' '\0' | xargs -n 1 -0 rm -if
$ sudo pkgutil --only-dirs --files <完整套件名稱> | tr '\n' '\0' | xargs -n 1 -0 rm -ifr

這兩個指令需要解釋一下,首先

pkgutil --only-files --files <完整套件名稱>

是透過 pkgutil 列出這個套件所有「檔案」的路徑

同理第二個就是列出套件所有「資料夾」的路徑。

pkgutil --only-dirs --files <完整套件名稱>

接著透過 tr 指令把換行和結尾符號去掉,再一次餵給 rm 指令去做批次刪除。

注意:

下刪除資料夾時要特別小心, pkgutil 可能會列出如 /Application 或 /usr 等路徑,如果刪了系統會有問題,因此保險起見還是先列出來用肉眼確認一下比較好!

Step 4:刪除系統套件記錄

最後一步,刪除套件在系統中的記錄

$ pkgutil --forget <完整套件名稱>

完成!

但如果待刪套件和範例一樣多,就必須再做 step 2 ~ step 4 五次……

快速移除 Mac 中的 pkg 程式! – 用 Python 實現

讓我們發揮工程師的懶人極致吧!寫一個 script 一勞永逸!

用 python 寫一個智慧的刪除流程吧!

我們的目標是寫一個 python script,他可以

  1. 依照關鍵字幫我列出待刪除的 pkgs
  2. 可以預覽待刪除的檔案與資料夾路徑
  3. 提供 dryrun,避免不小心誤刪

script 如下:

#!python3
from argparse import ArgumentParser
from re import sub
import subprocess

PKG_UTIL = 'pkgutil'
PKG_INFO = '--pkg-info'
PKGS = '--pkgs'
FILES = '--files'
ONLY_FILES = '--only-files'
ONLY_DIRS = '--only-dirs'
FORGET = '--forget'

RM = 'rm'
RM_IF = '-if'
RM_IFR = '-ifr'
SUDO  = 'sudo'

VOLUME = 'volume: '
LOCATION = 'location: '


def find_pkgs(pkg_keyword):
    result = subprocess.check_output([PKG_UTIL, PKGS]).decode()
    return [r for r in result.split('\n') if pkg_keyword.lower() in r.lower()]


def find_location(pkg_name):
    volume = ''
    location = ''

    result = subprocess.check_output([PKG_UTIL, PKG_INFO, pkg_name]).decode()
    for line in result.split('\n'):
        if VOLUME in line:
            volume = line.replace(VOLUME, '')
        elif LOCATION in line:
            location = line.replace(LOCATION, '')
    return f'{volume}{location}'


def get_pkg_files(pkg_name, pkg_location):
    return subprocess.check_output([PKG_UTIL, ONLY_FILES, FILES, pkg_name], cwd=pkg_location).decode().split('\n')


def get_pkg_folders(pkg_name, pkg_location):
    return subprocess.check_output([PKG_UTIL, ONLY_DIRS, FILES, pkg_name], cwd=pkg_location).decode().split('\n')


def preview_will_remove(pkg_name, pkg_location):
    files = get_pkg_files(pkg_name, pkg_location)
    print('Will remove files:')
    for file in files:
        print(file)
    print('---------------------')

    folders = get_pkg_folders(pkg_name, pkg_location)
    print('Will remove folders:')
    for folder in folders:
        print(folder)
    print('---------------------')


def remove_pkg_files(pkg_name, pkg_location):
    files = get_pkg_files(pkg_name, pkg_location)
    subprocess.call([SUDO, RM, RM_IF] + files, cwd=pkg_location)


def remove_pkg_folders(pkg_name, pkg_location):
    folders = get_pkg_folders(pkg_name, pkg_location)
    subprocess.call([SUDO, RM, RM_IFR] + folders, cwd=pkg_location)


def remove_pkg_reg(pkg_name, pkg_location):
    return subprocess.check_output([SUDO, PKG_UTIL, FORGET, pkg_name], cwd=pkg_location).decode()


def parse_args():
    parser = ArgumentParser()
    parser.add_argument('pkg_keyword', help='Keyword of pkg which you want to remove', type=str)
    parser.add_argument('--apply', action='store_true', help='Apply to macOS')
    parser.add_argument('--preview', action='store_true', help='Preview which file or folder will be delete')
    parser.add_argument('--no-folders', action='store_true', help='Do not delete folders')
    return  parser.parse_args()


def main():
    args = parse_args()
    pkg_keyword = args.pkg_keyword
    is_apply = args.apply
    is_preview = args.preview
    no_folders = args.no_folders

    pkg_names = find_pkgs(pkg_keyword)
    
    print('Will remove follows:')
    print('---------------------')

    for pkg_name in pkg_names:
        location = find_location(pkg_name)
        print(f'Name: {pkg_name}\nLocation: {location}')
        print('---------------------')
        if is_preview:
            preview_will_remove(pkg_name, location)
        if is_apply:
            remove_pkg_files(pkg_name, location)
            if not no_folders:
                remove_pkg_folders(pkg_name, location)
            rm_rest = remove_pkg_reg(pkg_name, location)
            print(rm_rest)

    if is_apply:
        print('Removed successfully!')
    else:
        print('Is dryrun, use --apply to remove from macOS.')


main()

其中大部分是把 shell command 用 function 包裝起來,並且 parse command 回傳的字串來取得結果。

簡單介紹幾個核心 function:

首先透過 find_pkgs() ,他包裝了原來這個指令的功能:

$ pkgutil --pkgs | grep <套件可能的名稱>

把 pkgutil 回傳的 pkg names 用換行符號分開,再轉小寫與使用者輸入的關鍵字作比較過濾後回傳。

def find_pkgs(pkg_keyword):
    result = subprocess.check_output([PKG_UTIL, PKGS]).decode()
    return [r for r in result.split('\n') if pkg_keyword.lower() in r.lower()]

得到 pkg 完整名稱,再用 find_location() 找出他們所在位置,包裝這個指令:

$ pkgutil --pkg-info <完整套件名稱>

此函數會把 volume 和 location 拼接在一起回傳完整的路徑。

如果有多個套件,可用 for loop 多次呼叫函數批次抓回來,不用像以往人工多次輸入查詢。

def find_location(pkg_name):
    volume = ''
    location = ''

    result = subprocess.check_output([PKG_UTIL, PKG_INFO, pkg_name]).decode()
    for line in result.split('\n'):
        if VOLUME in line:
            volume = line.replace(VOLUME, '')
        elif LOCATION in line:
            location = line.replace(LOCATION, '')
    return f'{volume}{location}'

最後取得 pkg files 並刪除,主要是實現以下指令:

$ sudo pkgutil --only-files --files <完整套件名稱> | tr '\n' '\0' | xargs -n 1 -0 rm -if

但我們需要有 dryrun 和預覽待刪除檔案路徑的功能,所以把「取得 pkg files」和「批次刪除檔案」分別用兩個 function 實現:

def get_pkg_files(pkg_name, pkg_location):
    return subprocess.check_output([PKG_UTIL, ONLY_FILES, FILES, pkg_name], cwd=pkg_location).decode().split('\n')

def remove_pkg_files(pkg_name, pkg_location):
    files = get_pkg_files(pkg_name, pkg_location)
    subprocess.call([SUDO, RM, RM_IF] + files, cwd=pkg_location)

其中 check_output() 裡的參數 cwd 是給定指令運作時的 work folder ,實現 shell 中我們先 cd 到目標資料夾的動作。

pkg folders 的部分同理,這邊就不再贅述,

最後就是 remove_pkg_reg(),刪除 macOS 中的套件記錄,主要實現以下指令:

$ pkgutil --forget <完整套件名稱>

python function 如下:

def remove_pkg_reg(pkg_name, pkg_location):
    return subprocess.check_output([SUDO, PKG_UTIL, FORGET, pkg_name], cwd=pkg_location).decode()

如何使用寫好的 python script?

使用一開始的範例,我們搜尋 fbws,python script 會回傳找到的套件全名,以及他所在的 location

$ ./rm_pkg.py fbws

Will remove follows:
---------------------
Name: tw.FirstBank.FBWS.第一銀行網銀安控程式.nspr.pkg
Location: /usr/local/Cellar/
---------------------
Name: tw.FirstBank.FBWS.第一銀行網銀安控程式.nss.pkg
Location: /usr/local/Cellar/
---------------------
Name: tw.FirstBank.FBWS.第一銀行網銀安控程式.FBWS.pkg
Location: /Applications/
---------------------
Name: tw.FirstBank.FBWS.第一銀行網銀安控程式.FBWS_Plugins.pkg
Location: /Library/Internet Plug-Ins
---------------------
Name: tw.FirstBank.FBWS.第一銀行網銀安控程式.com.hitrust.startFBWS.pkg
Location: /Library/LaunchAgents
---------------------
Is dryrun, use --apply to remove from macOS.

如果列出的 pkgs 都是要刪除的,就不用修改關鍵字,反之則需要調整,讓其回傳的套件都是要刪除的。

如果擔心刪除時會刪到系統資料夾,可以用 –preview 確認一下:

$ ./rm_pkg.py fbws --preview

Will remove follows:
---------------------
Name: tw.FirstBank.FBWS.第一銀行網銀安控程式.nspr.pkg
Location: /usr/local/Cellar/
---------------------
Will remove files:
nspr/._.DS_Store
nspr/4.14/._.DS_Store
nspr/4.14/lib/libnspr4.dylib
nspr/4.14/lib/libplc4.dylib
nspr/4.14/lib/libplds4.dylib

---------------------
Will remove folders:
nspr/
nspr/4.14/
.....

確認沒問題後,可以下 –apply 刪除,過程中會詢問密碼,因為需要 sudo 權限

$ ./rm_pkg.py fbws --apply


Will remove follows:
---------------------
Name: tw.FirstBank.FBWS.第一銀行網銀安控程式.nspr.pkg
Location: /usr/local/Cellar/

...

password: <your password>

Removed successfully!

如果發現會刪除到系統資料夾,使用 –no-folders 略過資料夾刪除,之後再手動處理

$ ./rm_pkg.py fbws --apply --no-folders

以後就能輕鬆移除 pkg 套件囉!

原始碼下載:github

延伸閱讀:
如何解決 Python Decimal.quantize() 發生 InvalidOperation
Python 詭譎的 default parameter value ,由踩坑來學習!

Written by J
雖然大學唸的是生物,但持著興趣與熱情自學,畢業後轉戰硬體工程師,與宅宅工程師們一起過著沒日沒夜的生活,做著台灣最薄的 intel 筆電,要與 macbook air 比拼。 離開後,憑著一股傻勁與朋友創業,再度轉戰軟體工程師,一手扛起前後端、雙平台 app 開發,過程中雖跌跌撞撞,卻也累計不少經驗。 可惜不是那 1% 的成功人士,於是加入其他成功人士的新創公司,專職開發後端。沒想到卻在採前人坑的過程中,拓寬了眼界,得到了深層的領悟。