Allen School 配发的 MacBook

题图:拿到了 MacBook
题图:拿到了 MacBook

UW CSE 的 PhD 要约信中提到新入学的研究生都会获得一个免费电脑(有多种选择),我选了(标准选项里的)MacBook。这篇博文记录我初探 macOS 的事情。作为探索结果,我居然又给微软报了一个 bug!

2 月 27 日更新 见此

UW CSE 提供的标准选项是 13 英寸的 MacBook Pro(无 Touch Bar)、Dell Latitude(14 英寸笔记本)或者 Dell Optiplex 3060(台式机),后两种选项可以选择 Windows 10 或者 Linux。我已经有了 Surface Book 2,所以再要一台 Windows 机器意义不大,而且因为 macOS 只能运行在 Mac 上,所以肯定是选 Mac 能覆盖更多使用场景,当然选 MacBook。

机器到了之后第一件事当然是设置机器,最重要的一点就是要把日常工作流搬运到 macOS 上。这点可是颇费周章。

日常操作和软件

macOS 的初始设置屏幕似乎有些对话框不支持 Tab 导航,这一度让我有点恼火。

我上次用 macOS 的时候还是在计蒜客用配发的 Mac mini,那个时候的操作系统还不支持用热键锁屏,于是那个时候我是用触发角启动屏幕保护程序来曲线救国(读作 workaround的(这也是 当时 Apple 社区版主推荐的方法)。新版本的 macOS 已经支持用 Ctrl+Command+Q 锁屏了。

我安装了适用于 macOS 的 Office 365 软件,可以说也只能说是差强人意,我用到的文档显示等一切正常,OneNote 的使用也 OK,Outlook 也能很好地同步我的邮件。一个闪光点是 OneNote 不会出现数学公式和正文的字体黏连问题(在 OneNote 2016 里把光标从数学公式里移出来继续打字会导致字体从 Calibri 变成 Cambria Math;在 OneNote for macOS 里不会)。几个缺点:ribbon 过于简化,很多操作需要回到菜单里;OneNote 的 ribbon 没有公式选项卡(不过 Word 有,而且我也比较熟悉 Office 的公式语法了);OneNote 不支持 Excel 工作表的 OLE;Outlook 不支持发送邮件到 OneNote;Outlook 没有简单的签名备份与还原工具;OneDrive 不支持文件占位符功能(可能是 macOS 的限制,也可能是 OneDrive 没做)(感谢热心网友周知,OneDrive Files on Demand for macOS 正处于预览阶段,很快就能看到它啦)。当然 COM 就不提了,肯定没戏啦!

其他软件如 Sourcetree、Visual Studio Code 都支持 macOS。至于 Safari,我费了一点功夫才发现需要设置才能用 Tab 对网页进行键盘导航。总的来说基本操作和软件还是可以适应的,只是需要很长时间磨合(比如记住热键,这真的挺困难的)。

Shell

可以修改设置来右键单击文件夹,然后可以选 Services > Open Terminal (Tab) at Folder 打开 Terminal 并定位到这个目录。我目前还没发现如何右键单击一个文件夹的空白处来在这里打开 Terminal。另外,我还不知道如何用键盘在 Finder 里打开一个文件。感谢热心网友和同学的提示,Command+O 可以打开文件(我潜意识里认为 Ctrl+O 是使用打开文件对话框的那种“打开”)。可否再请教一个问题:如何用键盘打开选中项目的上下文菜单?

作为 PowerShell 的使用者,我第一时间安装了 PowerShell for macOS(的稳定版),然后寻思着怎么方便地打开它。把 PowerShell 放到 Dock 不是很完美,因为 PowerShell 图标的那个玩意儿是一个 Terminal 启动器,点了之后会直接打开 Terminal(类似于在 Edge 里把网页固定到任务栏的效果)。探索了一番发现可以制作 Ocean 这个 Terminal profile 的副本,修改背景色为 PowerShell 背景蓝,修改字号和行数(个人习惯),然后在 Shell 选项卡里设置 Startup > Run command 为 exec pwsh 并选中 Run inside shell,这样从这个 profile 启动的时候 bash 就会被替换为 PowerShell 啦。

注意:直接修改 shell 程序为 PowerShell 的完整路径是不好的,一来这让以后有时使用 bash 的情况变麻烦,二来这会导致 PowerShell 没有合适的 PATH 变量,如果是在 bash 里面 exec pwsh,则 PowerShell 有合适的环境变量。

接着我在 PowerShell 里安装了我的 CommonUtilities、CommonAliases 和 WebAuthenticationBroker 包。CommonUtilities 里的 New-PasswordOut-TextEditor 运行良好;FastCredential 系列都不能用,因为 .NET Core for macOS 没有对应的实现;Sign-Scripts 不能正常用,因为 PowerShell for macOS 没有证书驱动器;Switch-User 不能用,因为我写的时候完全是给 Windows 准备的;Restart-Host 不能正常用,也是因为是针对 Windows 写的,不过稍加修改即可在 macOS 里面使用了(等效于 Start-Process bash -ArgumentList '-c "exec pwsh"' -NoNewWindow -Wait; exit)。WebAuthenticationBroker 运行良好,只是 Safari 会把 Microsoft Graph 最后的 native client 跳转地址当成下载文件,好在可以在下载好的文件里找到原来的地址,或者设置成每次下载之前询问,这时便可以获得地址。

PowerShell for macOS 里面去掉了很多 Windows PowerShell 专有的 alias,这些一般是当时为了让 nix 系用户快速上手 PowerShell 设置的 nix 命令对应物的别名,例如 lsGet-ChildItem 的别名。因为设置了别名会导致使用 native ls 命令十分困难(需要用 Start-Process),再加上其他考量,PowerShell for macOS 并没有这些别名。我从 Windows PowerShell 里面弄出来了这些别名,并在 $profile 里面设置上了这些别名:

# Windows
Get-ChildItem alias: |
    Select-Object Name, Definition |
    ConvertTo-Json -Compress |
    Set-Content "$env:USERPROFILE\OneDrive\winalias.json";

# macOS
$winalias = Get-Content '~/OneDrive/winalias.json' | ConvertFrom-Json;
$macalias = Get-ChildItem alias:
# Remove already-existing aliases
# Remove aliases to non-existent cmdlets
$addalias = $winalias |
    Where-Object { $_.Name -notin $macalias.Name } |
    Where-Object { (Get-Item -LiteralPath "alias:\$($_.Name)" -ErrorAction Ignore) -ne $null };
$addalias |
    ConvertTo-Json -Compress |
    Set-Content '~/.wpsh_aliases';

$profile 里面的内容还没写,先说说别的。因为我比较习惯有一个 C 盘(即使我真的也只有一个 C 盘),所以我还在 $profile 里面加了添加 PSDrive 的代码,把 macOS 的根目录变成 C 盘。所以完整的 $profile 长这个样子:

# $profile on macOS
& {

# Copy aliases.
Get-Content -LiteralPath '~/.wpsh_aliases' -Raw |
ConvertFrom-Json | Write-Output | ForEach-Object {
    New-Alias -Name ($_.Name) -Value ($_.Definition) -Description 'Alias copied from Windows PowerShell.' -Scope 'Global' -Option 'Constant';
};

# Import CommonAliases module.
Use-CommonAliases;

# Create drive "C" within PowerShell.
If ((Get-PSDrive -LiteralName 'C' -ErrorAction 'Ignore') -eq $null)
{
    New-PSDrive -Name 'C' -PSProvider 'FileSystem' -Root '/' -Scope 'Global' -Description 'Compatibility with Windows-style paths.';
}

# Change to drive "C" if current location is in it.
$local:currentLocation = Get-Location;
If ($currentLocation.Provider.ImplementingType -eq [Microsoft.PowerShell.Commands.FileSystemProvider] -and $currentLocation.ProviderPath.StartsWith('/'))
{
    Set-Location -LiteralPath "C:$($currentLocation.ProviderPath)";
}

} | Out-Null;

IIS 和 shell 脚本运行器

在 Windows 上我可以用 IIS 来托管我的博客调试页面,macOS 似乎某个版本开始取消了 System Preferences 里设置 web hosting 的功能。于是我使用 http-server 这个 NPM 包来代替。经过摸索,我发现可以这样在启动的时候自动运行这个服务器:

  1. 复制 PowerShell 的 Terminal profile 为 ServeBlog。
  2. 设置 ServeBlog 的 Startup > Run command 为 ~/node_modules/http-server/bin/http-server ~/Documents/BlogSiteRoot -p 80 -d false --gzip --silent -c 600 --no-dotfiles >/dev/null 2>/dev/null </dev/null & disown; exit
  3. 在 When the shell exits 下拉列表中选择 Close the window,并在 Ask before closing 单选按钮组中选择 Never。
  4. 导出 ServeBlog 为 ~/Documents/ServeBlog.terminal
  5. 在 System Preferences > Users & Groups > Current user > Login items 里添加 ServeBlog.terminal 文件。

基于 PowerShell 的工具

我经常用的工具有两个,一个是同步邮箱规则的小工具,另一个是 blog 构建系统。

邮箱规则同步器

我的 newsstand 项目是收集非人类电子邮件地址(通常是 newsletter 等),我认为这些邮件都不值得保存,所以用 Inbox Rule 给它们打上“To be swept”标记并且定期删除。需要自动化的任务是解析列表文件并把它们设置为 Inbox rule。为此我有一个 Update-MarkAsToBeSweptRule.ps1 命令。它使用 WebAuthenticationBroker 包完成用户登录、授权,在 macOS 上工作良好。

Blog 构建系统

我的 blog 构建器是一个 PowerShell 脚本,大概做以下几件事:

  • 复制静态资源
  • 找出所有需要构建的帖子,检查它们是否修改并用我修改过的 marked 把 Markdown 转换为 HTML
  • 把所有翻译过的 HTML 放入模板,并检查结果是否包含我设置的扩展语法,并进行各种处理,其中有些步骤是把 KaTeX 代码交给 KaTeX 编译器 翻译为 HTML
  • 根据构建好的帖子构建导航页面(主页、存档页、标签页等)

大多数内容的写法都是跨平台的,然而构建仍然不成功。

小问题是有些文件在 *nix 惯例下是隐藏文件,例如 .nojekyll 没有正确复制,因为 Get-ChildItem 的时候没有 -Force。这很容易解决。

大问题是 Node.js 调用不成功。KaTeX 和 marked 都是通过 Node.js 调用的,我的脚本会重定向标准流到文件。其中有一步是使用 fs.fsyncSync 方法来确保内容已经写入磁盘。在 Windows 上这没什么问题,然而在 macOS 上会有 EBADF 错误(无效的文件描述符)。

经过 查看代码,在 Windows 上的 StartWithCreateProcess 方法 会把文件句柄传入 STARTUPINFO 结构的 hStd??? 参数中,子进程的标准流是文件,而不是管道。在 macOS 上则采用 Process 类启动,子进程的标准流总是管道(逻辑最终会跳转到 ForkAndExecProcess 方法),接着 Start-Process 会负责把管道里的内容搬运到文件里(有点类似我的 Use-RawPipeline 在一些特定情况下的做法)。总之,有的时候重定向会导致 fsync 得到 EBADF,例如 Windows 的 Command Prompt 运行下面的代码也会有问题:

C:\>node>con
> require('fs').fsyncSync(1)
Error: EBADF: bad file descriptor, fsync
    at Object.fs.fsyncSync (fs.js:866:18)

知道了问题所在就很容易解决了,只要改变方式,把重定向改成传入文件名到参数里,即可让 JavaScript 代码完全掌握流的控制(用 & (Get-Command 'node' -CommandType 'Application' -TotalCount 1) $jsFile $stdin $stdout $stderr 确保正确的转义,我是不太敢用 Start-Process 了)。这篇博文就是在 macOS 上构建出来的。

此外,PowerShell for macOS 的文件重定向是有很严重的 bug 的——它误以为标准流总是文本,并且执行复制的时候还会吞掉或者增加空行。我已经把该问题在 PowerShell 的 GitHub 仓库贴出来了,它是 issue #8702

后话

这只是对用 macOS 干活的初探,以后肯定还会有很多需要探索和吐槽的地方。

2 月 27 日更新

我折腾出来了两个小工具:在文件、文件夹的上下文菜单上加入“使用 Code 打开”和“在这里打开 PowerShell”。都使用 Automator 实现的:前者是对 Finder 中选择文件、文件夹运行 shell 脚本;后者是对 Finder 中选择的文件夹运行 AppleScript。缺点是只有文件的上下文菜单里可以出现(在文件夹空白处的上下文菜单则没有),且不是主菜单而是次级菜单。注意:macOS 自带的“在这里打开 Terminal”只支持 POSIX shell,因为它的 cd 后面字符串的转义是 POSIX shell 风格的,如果你的 shell profile 的启动命令是 exec pwsh,那么有些情况输入的将不是有效的 PowerShell 命令;后面这个玩意儿其实就是先在 bash 里面 cd 完了再 exec pwsh 一下。

前者的代码见 这里,后者的则是 这里;这两者应该都是正确处理了转义的(吐槽:AppleScript 的替换字符串代码太鬼畜了)。这两者也是敦促 Microsoft 尽快给 macOS 版本的 Visual Studio Code、PowerShell 加入和 Windows 上一样方便的功能。

几个提示:

  • 可以从 /Applications/Visual Studio Code.app/Contents/Resources(PowerShell 同理)提取图标。
  • macOS 下如果文件名含有 \ 则无法用 PowerShell 或 Visual Studio Code 打开;如果文件名含有 /,则其 POSIX 文件名里这个字符其实是 :,可以被上面的代码处理。
  • 如果要完美模拟 Windows 上的“在这里打开 PowerShell”的效果,可以 cd 之后执行 clear; printf "\e[3J"; exec pwsh -NoLogo。同样,在 profile 里面的自动执行代码也可以使用 clear; printf "\e[3J"; exec pwsh

当天稍晚的更新 Unix 系的环境变量只有操作系统级别的控制和进程级别的控制简直太坑了!在 Windows 上,环境变量有操作系统级别的、用户级别的和进程级别的;在 Unix 系上,进程级别的环境变量完全靠 login shell 处理 profile 和继承实现,这会导致一些情况下你的进程“意外丢失”了需要的环境变量。之前我写的“用 Code 打开”的代码就遇到了这个问题,Automator 运行的 shell 脚本带有的 PATH 变量永远是不含任何 PATH 时运行 /usr/libexec/path_helper 决定出来的,在 macOS 上默认是 /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin,我自己设置的 ~/bin 不会出现。这就导致了用这个上下文菜单项打开的 Code 具有错误的 PATH,从而导致 LaTeX Workshop 不能正常工作,我还为此写了一个 issue。解决方法也很简单,在 Automator 里面的 shell script 里 exec bash --login … 即可(用处理了 profile 的 shell 代替完成任务);该问题在前述 issue 里面已经修正。

有些朋友建议我直接修改 /etc/paths 或者 /etc/paths.d,我觉得这是一种万不得已的解法,因为这些是操作系统级别的 PATH,这样做属于 用全局设置解决局部问题

请启用 JavaScript 来查看由 Disqus 驱动的评论。