分类存档: Develop

基于钉钉 + Virtual-LDAP + KeyCloak 的内网统一认证系统

0. 架构

基于钉钉的内网统一认证

1. 背景

最近公司内网的各种系统部署得越来越多,每个系统都有自己的认证方式和账号体系,这导致大家在每个地方都要去注册一个账号,并且不利于公司统一管理密码安全策略,例如密码到期策略、密码复杂度策略以及强制二次验证等。

以及对于一部分短平快的内网应用来说,我们并没有时间去为它开发一套用户体系,这个时候还是希望能有一个统一的前端反向代理来处理用户认证这个流程。

为此,我就去寻找了一些解决方案,并且为了解决开源系统并不能对接外部用户系统的问题,开发了一个 Node.JS Package(Virtual-LDAP)来提供 LDAP 能力。

2. 问题

虽然总的需求是一个很简单的功能,但是这其中还是有很多细节的问题需要考虑。

认证方式

对于有一些开源系统,它本身是支持 OAuth 来进行用户认证的,这个时候只需要去选择一个支持 OAuth 的用户管理系统就可以了,甚至基于开源库自己去开发一个也并不困难。但是对于某一些开源系统来说,它并没有提供 OAuth 认证接入的支持,只提供了 LDAP 接入。

例如最近我们引入了 Metabase 作为面向运营的快速 BI 分析工具,但是它除了可以接入 Google 账号作为认证方式,就只能接受 LDAP 作为认证方式了。这个时候就得去寻找一个认证系统,同时能支持 OAuth 以及 LDAP。

用户体系

引入用户管理系统之后,还有另外一个问题需要考虑,就是现在员工的用户体系需要去管理。对于一个小公司来说,目前并没有一个统一的工具去同时管理员工的各种信息以及状态。

但是目前公司已经引入钉钉作为公司的交流沟通工具,以及作为各种流程的审批处理系统,HR 也会在钉钉上去管理所有员工的状态,以及员工的组织架构。

因此,这个用户管理系统最好需要能支持同步钉钉中的用户体系,这样就不需要额外的人力去维护用户管理系统,以及在有员工状态变更的时候,能及时同步,避免出现授权外的访问。

数据同步

有了 OAuth,有了 LDAP,还有了钉钉这个数据源,因此就需要处理好各个 Provider 之间的数据同步问题,避免人工去维护各个系统里面的用户数据,做到以钉钉的数据为基础,用户管理系统提供能力,做到各个系统各个认证方式得到的用户数据都是一致的。

以及,对于 LDAP 来说,它是有组织架构的概念的,这个可以在对接到它的应用系统中,快速映射到应用中的用户组,实现用户的权限自动分配和管理,避免每次有新员工加入,都需要单独去配置一次各个业务系统的权限。因此,这也要求用户管理系统能够做到同步钉钉的组织架构,无需额外管理。

3. 解决方案

KeyCloak

KeyCloak Admin Console

KeyCloak 是一个开源软件产品,旨在为现代的应用程序和服务,提供包含身份管理和访问管理功能的单点登录工具。

KeyCloak 提供了丰富的功能用于公司内部单点登录上,包括:

  • 内置的用户账号管理界面
  • OpenID Connect 以及 OAuth 2.0 支持
  • LDAP 同步支持
  • 支持自定义主题

虽然之前也找到了 FreeIPA 这个开源系统,功能也同样强大,但是在部署过程中碰到了很多问题,最后还是选择了 KeyCloak。

Pomerium

Pomerium

对于大多数开源系统来说,可能都已经内置了对于 OAuth 或者 LDAP 的支持,对于这些系统,只需要按它的需求,去配置 OAuth 或者 LDAP 认证即可接入 KeyCloak。

但是对于一些静态页面,或者是自行开发的内部应用,可能并没有时间去额外添加用户认证支持,而这个需要就需要一个带用户认证支持的反向代理了。

Pomerium 就是这样一个反向代理应用,它作为一个 Identity-Aware Proxy,用于给内部应用增加安全访问的能力。

Pomerium 的主要功能有:

  • 支持接入 OAuth 认证
  • 支持配置内部应用允许哪些域,或者哪些用户可以访问
  • 支持 WebSocket 转发
  • 支持转发时自定义 HTTP Header
  • 支持 JWT 或 HTTP Header 传递用户 Session 信息到后端内部应用
  • 提供一个隐式 domain/.pomerium 路径来显示当前用户信息
  • 支持使用 Wildcard SSL 证书给后端服务统一增加 SSL 支持

通过 Pomerium,就可以将内部不带用户认证的应用直接加上用户认证的能力,避免非授权访问,并且可以直接通过 JWT 传递过来的用户信息,进行额外的用户权限管理。

Virtual-LDAP

Virtual-LDAP 是我开发的一个 Node.JS 程序包,用于使用自定义数据源来提供一个 LDAP 服务,可以将非 LDAP 用户系统(目前支持钉钉)对接到只支持 LDAP 认证的系统当中。并且支持保存用户密码到数据库中,以使用与非 LDAP 用户系统不一样的密码数据。

Virtual-LDAP 主要功能有:

  • 定时同步钉钉组织架构以及员工信息
  • 提供基本 LDAP 功能,包括 bind、search、modify 等操作
  • 通过配置文件配置管理账号
  • 独立保存用户密码,以 SHA256+Salt 存储
  • 支持自定义分组能力,在钉钉组织架构之外扩展用户分组
  • 支持自定义用户数据 Provider,可以自行开发接入钉钉以外的用户系统

Virtual-LDAP 主要解决的问题是市面上的 LDAP 服务系统并不支持接入钉钉用户系统,钉钉也并没有提供一个 LDAP 方式的数据源供企业内部使用,因此需要额外的系统去对接钉钉的用户系统,以 LDAP 方式提供用户数据,供 KeyCloak 及其他内部系统使用。

当然,Virtual-LDAP 并不是一个全功能的 LDAP 服务器,它仅支持有限的 LDAP 操作,但是在对接到 KeyCloak 作为 User Federation 已经足够用了,以下功能都可以正常使用:

  • User Synchronize
  • Group Synchronize
  • User 和 Group 的映射关系,以及完整的组织架构
  • 在认证时,使用 LDAP bind 进行认证
  • 修改密码时,使用 LDAP modify 同步保存密码

通过 Virtual-LDAP,就可以直接由 HR 去管理公司的员工以及组织架构,并且不需要额外再去另外一个系统中同步维护相关信息,Virtual-LDAP 会使用钉钉 OpenAPI 自动从钉钉获取最新的员工列表以及组织架构信息。

4. 部署

对于 KeyCloak 和 Pomerium 来说,官方均已提供 Docker 镜像,因此直接通过 Docker 即可以快速部署使用。

  • KeyCloak Docker:https://hub.docker.com/r/jboss/keycloak
  • Pomerium Docker:https://hub.docker.com/r/pomerium/pomerium

对于 Virtual-LDAP 来说,可以直接从源代码运行,也可以自己编写一个 Dockerfile 来使用 Docker 部署。

一个典型的 Virtual-LDAP Dockerfile 可以像下面这样:

Dockerfile

FROM node:13.7.0-alpine

WORKDIR /app
COPY package.json /app/
RUN npm install

COPY index.js /app/
COPY config.js /app/

CMD [ "node","index.js" ]

index.js

const server = require('virtual-ldap');

server.setupVirtualLDAPServer(require("./config"));
server.runVirtualLDAPServer();

package.json

{
"dependencies": {
"virtual-ldap": "^0.1.1"
}
}

当然,必需的配置文件 config.js 也是不可少的,具体如何配置可以参考 config.sample.js

5. 小结

总的来说,如果有额外的人力去维护两份员工信息以及组织架构,只需要 KeyCloak 就可以解决以上很多问题。但是对于小公司来说,使用额外的人力总归不是很高效的方式,并且钉钉拥有更完整的用户系统管理功能,对于 HR 来说使用上可能也更为友好。

希望 Virtual-LDAP 能给同样希望在公司内部部署统一认证系统,以及使用钉钉作为企业交流沟通的朋友们有所帮助。

6. 参考

—EOF—

阿里云 IoT LinkDevelop 案例实战

阿里云 IoT 在 3 月份的时候发布了 Link Develop 一站式开发平台,我根据平台的文档,编写了几个示例,便于外部开发者们来快速理解和掌握怎样使用 Link Develop 平台去加速和辅助物联网产品的开发工作。

示例包括空气监测站、智能灯、Home Assistant 设备接入等,涉及的技术包括 Arduino、React、iOS、前端开发等。

通过这个文档,可以快速了解 Link Develop 一站式开发平台所提供的功能,以及怎样基于平台实现一个物联网产品。

文档配套示例代码同样提供在 GitHub 中。

目录:

GitHub 地址:

https://github.com/aliyun-linkdevelop/linkdevelop-practice

https://github.com/aliyun-linkdevelop/airmonitor_demo

https://github.com/aliyun-linkdevelop/smartlight_demo

[笔记] Objective-C 中模拟泛型及 Xcode 格式化代码插件

记录一些 iOS & OS X 开发过程中学到或有趣的东西。

Objective-C 中模拟泛型

今天看到的一个辅助代码,可以在 Objective-C 代码中添加泛型的支持:

泛型是程序设计语言的一种特性。允许程序员在强类型程序设计语言中编写代码时定义一些可变部份,那些部份在使用前必須作出指明。各种程序设计语言和其编译器、运行环境对泛型的支持均不一样。(via )

泛型一般出现在 .NET、Java 或 C++ 的代码里面。

在 Objective-C 里面,像数组之类的容器,在取单个元素时,返回类型都是 id,这个时候如果要使用返回对象的一些属性,就需要先转换一下类型,或者使用 getter 方法来获取。

如果用了泛型之后,就很方便了,可以直接以 arr.lastObject.prop 的方式来访问。

使用了上面这个代码使 Objective-C 支持泛型后,就可以像下面这样写代码了:

generics example

这个辅助代码是一个大宏,给 Objective-C 添加泛型支持是以以下几个方面来做到的,例如给 MyClass 添加泛型支持来说明:

  1. 定义一个 MyClass 的 Protocol
  2. 给各个容器类(例如 NSArray、NSMutableArray、NSSet)添加 Category,重新定义各个容器操作方法的返回值和参数类型
    1. 获取单个元素的方法返回值类型修改为 MyClass *
    2. 返回容器的方法返回值类型修改为 NSArray<myclass> *
    3. 参数类型同样处理

这样在使用 NSMutableArray</myclass><myclass> *arr 这样一个变量时,如果添加的元素不是 MyClass * 类型,就会发出警告了。

不过因为 Objective-C 在编译时并不会强制要求变量类型一致,所以如果传递一个非 MyClass *类型的参数,也是可以编译通过的。

当然这个库只能说是在一定程度上方便编写代码而已,至于用不用还是见仁见智了,每次要写 NSArray</myclass><myclass *> 也不见得更方便。

Xcode 代码格式化插件 BBUncrustifyPlugin

插件地址:

这个插件算是对 uncrustify 的一个快捷引用了。

可能会比 Xcode 自带的 Re-indent 功能强大一点点 :)

另外,还有一个图形化的参数配置工具,可以用来配置 uncrustify 那好几百项目的参数:UncrustifyX

参考资料

  1. http://iosdevelopertips.com/objective-c/generics-in-objective-c.html
  2. https://github.com/tomersh/Objective-C-Generics
  3. https://github.com/benoitsan/BBUncrustifyPlugin-Xcode
  4. http://zh.wikipedia.org/wiki/%E6%B3%9B%E5%9E%8B
  5. http://uncrustify.sourceforge.net

—EOF—

在 Sandboxed Mac App 中嵌入第三方可执行文件

之前开源了 一个 gitstats 的 GUI 应用 GitStatX,在提交到 GitHub (GitStatX) 之后,又准备提交到 Mac App Store。

在提交到 Mac App Store 之后,出现了一些问题,程序中包含的第三方可执行文件没有签名,导致苹果拒绝了提交的程序包:

App sandbox not enabled – The following executables must include the “com.apple.security.app-sandbox” entitlement with a Boolean value of true in the entitlements property list. Refer to the App Sandbox page for more information on sandboxing your app.

  • GitStatX.app/Contents/Resources/git/bin/git
  • GitStatX.app/Contents/Resources/gnuplot/gnuplot

但是 GitStatX 所包含的 git 以及 gnuplot,并不是我程序中的代码,也没有 Xcode 工程去使用一个 entitlements 文件来指定它为启用 sandbox 状态。

所幸在网上搜索的时候,找到了可以使用 codesign 工具来进行签名的方法。

检查可执行文件是否启用 sandbox

codesign --display --entitlements - ./commandlinetool

给可执行文件签名并启用 sandbox

先在命令行工具同目录创建一个 entitlements.plist:

< ?xml version="1.0" encoding="UTF-8"?>
< !DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.security.app-sandbox</key>
    <true></true>
    <key>com.apple.security.inherit</key>
    <true></true>
</dict>
</plist>

这里设置了 com.apple.security.app-sandbox 为 true 来启用 sandbox。

然后使用 codesign 进行签名:

codesign -s "3rd Party Mac Developer Application: Your Name" --entitlements ./entitlements.plist ./commandlinetool

记得把 “3rd Party Mac Developer Application: Your Name” 替换为实际的证书名称。

问题

在给 gnuplot 签名之后,提交到 Mac App Store,苹果还是会自动验证并发邮件说 gnuplot 没有签名,于是在本地直接导出 GitStatX.app,并检查了一下,发现 gnuplot 的 entitlements 又没有了,但是 git 的 entitlements 还是保留的。在 gnuplot 同目录下,有一个 _CodeSignature 目录,可能是在打包的时候会自动处理。

为了避免这个问题,我就把 gnuplot 也放到了一个 bin 目录下,然后再打包并检查,发现 gnuplot 已经是正确签名并且保留有 entitlements 的了。

当然,GitStatX 最终也正确提交到 Mac App Store,并且程序进入了 Waiting For Review 状态。

参考资料

  1. Mac OS app, sandbox with command line tool?
  2. Checking Code Signing and Sandboxing Status in Code
  3. Entitlement Key Reference

— EOF —

开源 GitStatX:一个 gitstats 的 GUI 应用程序

GitStatX 是一个 gitstatx 的 GUI 应用程序,用于方便在 Mac OS X 中使用 gitstats。

一般情况下,如果要在 Mac OS X 上使用 gitstats,需要自行安装 gnuplot,而这需要使用 macports 或者 homebrew,通常这会比较费时间,并且只能使用命令行来使用 gitstats 生成所对应 git 仓库的报告。

GitStatX 提供了一个 GUI 来使用 gitstats,具备以下功能:

  • 同时管理多个项目
  • 使用分组来归类各个项目
  • 标识项目类型
  • 自动生成报告
  • 导出仓库的活动报告

截图

项目主页

仓库地址

下载

查看所有下载

授权

本软件及代码以 GPLv3 授权发行。

相关代码

  • 使用 bootstrap 样式的 gitstats:

  • 修正可执行文件依赖库的脚本:
  • 联系我

    — EOF —

    使用 ukraine 建设 node.js 私有云

    源由

    node.js 越来越流行,托管 node.js 应用的云服务也越来越多,例如 nodejitsuheroku 等。

    但是这些云服务通常有这样那样的限制,又或者是要收费的。而有些时候我通常不需要跑很大的应用,或者是很稳定的应用,只是为了跑一些小的,或者是学习用的 node.js 应用,并且我也有自己的 VPS,想把这些应用托管在自己的服务器上。

    于是我需要去找一个可以在自己的 VPS 上建设一个 node.js 私有云的软件。

    比较

    在看了 中的 DIY Platforms 后,尝试了一下其中介绍的平台:

    • nodester: 安装比较麻烦,不支持新版本的 nodejs,安装说明还是针对 node 0.4.11 的
    • CloudFoundry: 比较庞大,而且是以 vm 方式安装,不适合 VPS
    • OpenShift: 同 CloudFoundry,不只支持 node.js,安装复杂,不适合 VPS
    • Nodejitsu: Nodejitsu 开源了他们所用的 node.js 应用管理项目 haibu,haibu 安装比较简单,而且支持最新的 nodejs 0.8.16,不过 Nodejitsu 同样开源的命令行客户端 jitsu 并不支持 haibu
    • Stagecoach: 文档不够清晰,看了很久也没明白它的架构和怎么部署⋯⋯

    这样看来似乎没有一个可以满足我的需要,不过 GitHub 是强大的,通过搜索找到了 ukraine 这个项目:

    ukraine glues haibu and node-http-proxy adding a little helper, chernobyl, that deploys into this cloud. It is probably as stable as you think it is.

    这就是我想要的。

    修改

    原始的 ukraine 虽然已经基本满足了我的需要,但是还有一些小的功能需要增加:

    1. 使用 nginx 作为前端,这样 node.js 应用可以部署在 nginx 后面,与 PHP 等项目并存
    2. 使用 SSL 保护 haibu 的服务端,防止 auth_token 因为不加密的 HTTP 通信而泄漏
    3. 因为使用 nginx 作为前端,所以 haibu 服务端和 node-http-proxy 都不需要监听 0.0.0.0,而只需要监听 127.0.0.1
    4. 防止 node.js 应用监听了常用端口而导致其他应用启动失败,因为使用了 nginx 作为前端,node.js 应用本身监听了什么端口就不重要了
    5. 防止 node.js 应用直接对外提供服务,同样因为已经有 nginx,node.js 应用只需要监听 127.0.0.1 就行了
    6. chernobyl 不支持配置每个不同的 ukraine 监听在哪个端口,以及有没有配置 SSL
    7. 我想 ukraine 作为一个服务存在,这样在 VPS 启动时可以自动启动
    8. node.js 应用需要支持绑定自定义域名,而不是只能绑定子域名

    所以我 fork 了 radekstepan 的 ukraine 到 ,并做了一些自己需要的修改。

    安装修改后的 ukraine

    如果你和我一样,也需要一个这样简单的 node.js 私云,那么以下的内容可以帮助里部署 ukraine 到自己的 VPS 上。

    注意:安装教程以在 Ubuntu/Debian 上为例,并且所有命令是以 root 用户执行。

    1. 安装 node.js

    haibu 需要 node.js 的版本大于 0.8,所以需要安装最新的 node.js 包,或者自行编译安装。

    参考这篇文章:Installing Node.js via package manager

    2. 安装 forever

    forever 是用来维持 ukraine 一直在启动状态

    npm install forever -g
    

    3. 配置 nodejs 用户

    为了使所有 node.js 应用不使用 root 权限运行,防止出现权限方便的风险,需要添加一个用户 nodejs 来运行 node.js 应用。

    groupadd nodejs
    useradd -g nodejs -m -s /bin/bash nodejs
    

    4. 获取并安装 ukraine

    为了管理方便,这里安装 ukraine 到 /srv/ukraine 中,如果你不安装在这个位置,那么相关的脚本和配置文件都需要修改。

    cd /srv
    git clone https://github.com/ohdarling/ukraine
    cd ukraine
    git checkout private-cloud
    npm install
    chown -R nodejs.nodejs /srv/ukraine
    

    5. 配置 ukraine

    cd /srv/ukraine
    cp config.example.json config.json
    vim config.json
    

    为了安全起见,建议 auth_token 不要留空。

    example.com 需要替换为你自己的域名,这样以后部署了 node.js 应用时,会自动分配一个 package-name.example.com 的子域名。

    6. 安装服务脚本

    注意:这个脚本只适用于 Ubuntu/Debian。

    cd /srv/ukraine
    cp server/init-script/ukraine /etc/init.d/
    chmod +x /etc/init.d/ukraine
    

    7. 使用 nginx 作为前端服务

    为了使 node.js 应用与原有的 PHP 共存,使用 nginx 作为 ukraine 的前端服务。

    注意:部署 node.js 应用到 ukraine 需要 nginx 启用 chunkin 模块,默认情况下 nginx 并没有安装此模块,可以自行编译安装(参考 ),或者直接使用包管理器安装 nginx-extras,这个包中包含的 nginx 已经编译了 chunkin 模块。

    添加以下配置文件内容到 /etc/nginx/sites-available/ukraine,并且在 /etc/nginx/sites-enabled/ 中添加一个到配置文件的符号链接。注意,需要替换配置文件内容中的 haibu.example.com*.example.com 为你自己的域名。

    server {
        listen   80;
        server_name  haibu.example.com;
    
        access_log  /var/log/nginx/localhost.access.log;
    
        chunkin on;
    
        error_page 411 = @my_411_error;
            location @my_411_error {
            chunkin_resume;
        }
    
        location / {
            proxy_pass http://localhost:9002;
            proxy_set_header  X-Real-IP  $remote_addr;
        }
    }
    
    server {
        listen   80;
        server_name  *.example.com;
    
        access_log  /var/log/nginx/localhost.access.log;
    
        location / {
            proxy_pass http://localhost:8000;
            proxy_set_header  X-Real-IP  $remote_addr;
            proxy_set_header Host $host;
        }
    }
    

    建议在 haibu.example.com 这个站点上启动 SSL 来保护 auth_token。

    添加完配置文件后,使用以下命令让 nginx 重新载入配置:

    nginx -s reload
    

    8. 启动 ukraine

    service ukraine start
    

    9. 检查 ukraine 是否正常运行

    打开浏览器,访问 http://haibu.example.com/version,将会看到以下内容:

    {"version":"haibu 0.9.7"}
    

    注意,如果在之前已经配置了 auth_token,将会看到:

    {"message":"Wrong auth token"}
    

    这说明 ukraine 已经正常启动。

    部署自己的 node.js 应用

    首先需要在本地安装 ukraine:

    npm install -g git://github.com/ohdarling/ukraine\#private-cloud
    

    如果之前配置了 auth_token:

    chernobyl config haibu.example.com auth_token=xxxx
    

    如果之前配置了 SSL:

    chernobyl config haibu.example.com https=true
    chernobyl config haibu.example.com haibu_port=443
    

    现在可以部署 node.js 应用了,进入到 node.js 应用的根目录,运行以下命令:

    chernobyl deploy haibu.example.com .
    

    这就会部署这个 node.js 应用到 haibu.example.com 了。

    给 node.js 应用绑定自定义域名

    在给 node.js 绑定自定义域名,只需要在 package.json 中添加 domains 属性即可:

    {
        "name": "example-app",
        "version": "0.0.2",
        "domains": [
            "custom-example.com"
        ]
        "dependencies": {
            "express": "2.5.x"
        },
        "scripts": {
            "start": "server.js"
        }
    }
    

    同样需要修改 nginx 的配置文件,把自定义域名加到 server_name 中:

    server {
        listen   80;
        server_name  *.example.com, custom-example.com;
    
        access_log  /var/log/nginx/localhost.access.log;
    
        location / {
            proxy_pass http://localhost:8000;
            proxy_set_header  X-Real-IP  $remote_addr;
            proxy_set_header Host $host;
        }
    }
    

    注意事项

    1. 所有 node.js 应用会监听在一个随机端口,并且会监听在 127.0.0.1,也就意味着在外部没有办法直接访问这个应用
    2. package.json 中 scripts.start 属性,不需要带 node,只需要指定以哪个脚本启动即可,例如以下是错误的:
        {
            "name" : "example-app",
            "scripts" : {
                "start" : "node server.js"
            }
        }
    
    1. 如果需要 node.js 鉴定特定的端口,并能直接对外服务,可以在 package.json 的 env 属性中添加 “HAIBU_INDEPENDENT_SERVICE”: “true”,例如:
        {
            "name" : "somesocks",
            "scripts" : {
                "start" : "server.js"
            },
            "env" : {
                "HAIBU_INDEPENDENT_SERVICE" : "true"
            }
        }
    

    问题及反馈

    你可以在 GitHub 上 fork 这个仓库:

    联系我

    Twitter: @ohdarling88
    Email: ohdarling88 at gmail.com

    感谢

    参考资料

    1. https://github.com/joyent/node/wiki/Node-Hosting
    2. https://github.com/ohdarling/ukraine
    3. https://github.com/radekstepan/ukraine
    4. https://github.com/nodejitsu/haibu
    5. https://github.com/nodejitsu/haibu-carapace
    6. Installing Node.js via package manager

    — EOF —

    Developer ID Application 在 Mac OS X 10.7.5 下不能启动的问题

    记录一下最近发布阿里旺旺 for Mac 3.0.1 时碰到的问题。

    阿里旺旺在发布到 labs.etao.com 时,为了兼容 Mac App Store 版本,也要加上 Sandbox 支持,所以 App 是使用 Developer ID Application 方式发布的。

    但是有一些人在拿到新版本后,却没办法运行程序,总是会提示以下错误:

    Exception Type:  EXC_BAD_INSTRUCTION (SIGILL)
    Exception Codes: 0x0000000000000001, 0x0000000000000000
    
    Application Specific Information:
    dyld: launch, running initializers
    /usr/lib/libSystem.B.dylib
    xpchelper reply message validation: code signature invalid
    The code signature is not valid: The operation couldn’t be completed. (OSStatus error -67061.)
    
    Application Specific Signatures:
    code signature invalid
    

    一开始以为是签名的问题,然后另外一个同事使用同样方式发布 Developer ID Application 继续不能运行。于是去网上找了一下这个问题,发现了一篇文章《OS X 10.7.5 FAILS TO LAUNCH CODE-SIGNED APPS 》讲到这个问题,说这个可能是因为苹果在 Mac OS X 10.7.5 中改了什么东西导致的,只要重签名一下 App 就可以了。

    打开终端,进入要签名的 App 所在目录,使用以下命令来重新签名:

    codesign -fs 'Developer ID Application' --prefix 'com.taobao' \
    --preserve-metadata=i,e,res,req --timestamp=none AliWangwang.app
    

    注意:如果你的 Keychain Access 里有多个 Developer ID Application 证书的话,需要把 Developer ID Application 替换成 Keychain Access 中完整的 Developer ID Application 证书的名称。

    重新签名之后的 Developer ID Application,就可以在 Mac OS X 10.7.5 上正常打开和使用了。

    参考资料

    1. OS X 10.7.5 FAILS TO LAUNCH CODE-SIGNED APPS

    — EOF —

    一个修正 Mach-O 文件加载共享库路径的脚本

    在 Mac 上用到一些开源的程序,经常需要自己编译,这个时候一般会使用 macports 所提供的工具链进行编译。

    在编译的过程中,开源程序所引用的各种其他库,例如 libssl、libz、libgd 之类的,一般都会在 /opt/local/lib 下,而如果把这个编译好的二进制文件给其他人用时,如果其他人没有安装 macports,那么就会缺失这些共享库,从而导致编译好的二进制程序无法运行。

    具体原因就是因为 Mac 下链接共享库时,会在链接时将共享库的路径写入到最终的二进制文件中,而默认情况下,这个路径是绝对路径,例如 /opt/local/lib/libz.1.dylib。

    所幸苹果提供了 install_name_tool 这个工具来修改共享库的路径。

    下面这个脚本就是用来批量替换二进制文件中的共享库路径。

    这个脚本会在当前目录下创建 lib 目录,将二进制文件所依赖的 /opt/local/lib 中的共享库复制到 lib 目录中,并修改二进制文件中保存的共享库的路径。

    脚本的使用方法,将上面的脚本保存为 fixlibpath.sb,并加上可执行权限,放到要修正的二进制文件所在的目录:

    chmod +x fixlibpath.sh
    ./fixlibpath.sh mybinary
    

    还有共享库会依赖其他共享库的情况,所以也需要对 lib 中复制过来的共享库进行同样的处理:

    for f in lib/*; do
        ./fixlibpath.sh $f
    done
    

    可以观察一下脚本的输出,如果输出的内容中,每个共享库所依赖的库路径中不存在 /opt/local/lib,那就表示处理完成了。每个共享库都会有一个 /opt/local/lib 路径的,但是名称与这个共享库一样的路径,这个是不用处理的。

    要测试处理完成的二进制文件是否正常,可以将该二进制文件和 lib 目录复制到一台没有安装 macports 或者没有 macports 安装相关共享库的机器上运行,如果能正常运行就表示处理成功了。

    参考资料

    1. http://www.mikeash.com/pyblog/friday-qa-2009-11-06-linking-and-install-names.html
    2. http://stackoverflow.com/questions/4677044/how-to-use-dylib-in-mac-os-x-c#answer-11585225

    — EOF —

    [Tips] 适配 iOS App 到 iPhone 5 和 iOS 6

    iPhone 5 和 iOS 6 正式发布后,iOS 开发者们就要开始做 iPhone 5 和 iOS 6 的适配工作了 :)

    在迁移我自己的几个 iOS App 过程中,发现了老的 Xcode 工程代码在 iOS 6 中无法响应屏幕旋转的问题,在这里记录一下。顺便记录一下怎样适配 iOS App 到 iPhone 5 给还不知道怎么做的朋友们。

    适配 iPhone 5 的 4 寸屏幕


    iOS App LetterBox Mode

    iPhone 5 的屏幕分辨率为 640×1136,默认情况下,老的 iOS App 会以上下加两条黑边的模式来运行,程序实际占用的分辨率还是 640×960,保证了原有的 iOS App 在不修改的情况下也可以正常运行。

    如果需要适配 iPhone 5 的 4 寸屏幕,是一件很简单的事,只需要添加一张对应 iPhone 5 启动图片即可。

    iPhone 5 的分辨率为 640×1136,所以只需要添加一张分辨率为 640×1136 的启动图片到 iOS App 工程即可。启动图片需要命名为 Default-568h@2x.png,因为 iPhone 5 没有非 Retina 屏幕的型号,所以不需要 Default-568h.png 这样一个文件了。

    添加了对应 iPhone 5 分辨率的启动图片后,iOS App 就会以 640×1136 的分辨率运行了。

    当然了,如果 iOS App 里在计算 view 的 frame 时,使用了固定的 320×480 来计算的话,在显示时就会有一些问题,这时候就需要动态根据 view 的 frame 大小去计算,而不是使用 Hard Code。

    另外,也可以灵活使用 autoresizingMask,这样在未来 iPhone 又有其他分辨率,又或者出现了 iPad mini 的时候更方便处理。

    解决旧的 iOS App 在 iOS 6 中无法随屏幕旋转的问题

    如果你的 iOS App 支持随屏幕旋转而旋转,但是到了 iOS 6 中,发现这个功能不生效了,那么就要检查一下是不是因为旧版本的 Xcode 创建工程的原因。

    但是在 iOS SDK Release Notes for iOS 6.0 中的 UIKit 一节中说到“Autorotation is changing in iOS 6. In iOS 6, the shouldAutorotateToInterfaceOrientation: method of UIViewController is deprecated. In its place, you should use the supportedInterfaceOrientationsForWindow: and shouldAutorotate methods.”,并且详细解释中也说明了 iOS 6 中的改动:

    More responsibility is moving to the app and the app delegate. Now, iOS containers (such as UINavigationController) do not consult their children to determine whether they should autorotate. By default, an app and a view controller’s supported interface orientations are set to UIInterfaceOrientationMaskAll for the iPad idiom and UIInterfaceOrientationMaskAllButUpsideDown for the iPhone idiom.

    A view controller’s supported interface orientations can change over time—even an app’s supported interface orientations can change over time. The system asks the top-most full-screen view controller (typically the root view controller) for its supported interface orientations whenever the device rotates or whenever a view controller is presented with the full-screen modal presentation style. Moreover, the supported orientations are retrieved only if this view controller returns YES from its shouldAutorotate method. The system intersects the view controller’s supported orientations with the app’s supported orientations (as determined by the Info.plist file or the app delegate’s application:supportedInterfaceOrientationsForWindow: method) to determine whether to rotate.

    The system determines whether an orientation is supported by intersecting the value returned by the app’s supportedInterfaceOrientationsForWindow: method with the value returned by the supportedInterfaceOrientations method of the top-most full-screen controller.

    The setStatusBarOrientation:animated: method is not deprecated outright. It now works only if the supportedInterfaceOrientations method of the top-most full-screen view controller returns 0. This makes the caller responsible for ensuring that the status bar orientation is consistent.

    For compatibility, view controllers that still implement the shouldAutorotateToInterfaceOrientation: method do not get the new autorotation behaviors. (In other words, they do not fall back to using the app, app delegate, or Info.plist file to determine the supported orientations.) Instead, the shouldAutorotateToInterfaceOrientation: method is used to synthesize the information that would be returned by the supportedInterfaceOrientations method.

    说明中提到,在 iOS 6 中系统会通过查询 rootViewController 来判断是否可以旋转视图,但是在以前,使用 Xcode 创建一个工程时,通常会有以下代码来显示主窗口:

    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {    
        // Add the tab bar controller's view to the window and display.
        [self.window addSubview:tabBarController.view];
        [self.window makeKeyAndVisible];
     
        return YES;
    }

    在这里,并没有设置 self.window 的 rootViewController,而且在 MainWindow.xib 中,也没在连接 window 的 rootViewController 到默认的 UITabBarController。

    因此,我们有两种方法来解决这个问题:

    1. 在代码中设置 rootViewController

    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {    
        // Add the tab bar controller's view to the window and display.
        self.window.rootViewController = tabBarController;
        [self.window addSubview:tabBarController.view];
        [self.window makeKeyAndVisible];
     
        return YES;
    }

    2. 在 MainWindow.xib 中连接 window 的 rootViewController

    两种方法任意选择一种就行了。

    当然使用新版本 Xcode 创建的工程应该是没问题的,而且新版本 Xcode 的工程模板通常会使用代码来创建 rootViewController,而且默认也不再生成 MainWindow.xib。

    参考资料

    1. iOS SDK Release Notes for iOS 6

    —EOF—

    Mac OS X 中实现支持任意位置拖动的不规则窗体

    Mac OS X 中实现支持任意位置拖动的不规则窗体

    在开发 Mac OS App 时,根据视觉设计师的需要,有些时候会要实现一些不规则的窗口。一般不规则窗口都不包含 Mac OS X 标准窗口的标题栏,这样用户就没有办法通过拖动标题栏来拖动窗口。

    为了能让用户拖动一个无标题栏的窗口,我们就需要自己去处理这个移动窗口的过程。另外,由于窗口有可能是不规则的,那么同样就没有办法拖动窗口边缘来改变窗口大小了,同样需要自己进行处理。

    因为要处理鼠标点击事件,因此我们先创建一个继承于 NSWindow 的类 JWBorderlessWindow,以下代码都是此类的代码实现。

    初步实现

    //
    //  JWBorderlessWindow
    //
    //  Created by Xu Jiwei on 12-9-3.
    //  Copyright (c) 2012 xujiwei.com. All rights reserved.
    //
     
    @implementation JWBorderlessWindow
     
    - (BOOL)canBecomeKeyWindow {
        return YES;
    }
     
     
    - (BOOL)canBecomeMainWindow {
        return YES;
    }
     
     
    - (NSRect)resizeAreaRect {
        const CGFloat resizeBoxSize = 20.0;
     
        // 窗口右下角 20x20 的区域为改变窗口的区域
        NSRect frame = [self frame];
        NSRect resizeRect = NSMakeRect(frame.size.width - resizeBoxSize, 0,
                                       resizeBoxSize, resizeBoxSize);
     
        return resizeRect;
    }
     
     
    // 处理鼠标按下事件
    - (void)mouseDown:(NSEvent *)event {
        NSPoint pointInView = [event locationInWindow];
     
        // 判断是否点击在改变窗口大小区域
        BOOL resize = NSPointInRect(pointInView, [self resizeAreaRect]);
     
        NSWindow *window = self;
        NSPoint originalMouseLocation = [window convertBaseToScreen:[event locationInWindow]];
        NSRect originalFrame = [window frame];
     
        while (YES) {
            // 捕获鼠标拖动或鼠标按键弹起事件
            NSEvent *newEvent = [window nextEventMatchingMask:(NSLeftMouseDraggedMask | NSLeftMouseUpMask)];
     
            if ([newEvent type] == NSLeftMouseUp) {
                break;
            }
     
            // 计算鼠标移动的偏移
            NSPoint newMouseLocation = [window convertBaseToScreen:[newEvent locationInWindow]];
            NSPoint delta = NSMakePoint(newMouseLocation.x - originalMouseLocation.x,
                                        newMouseLocation.y - originalMouseLocation.y);
     
            NSRect newFrame = originalFrame;
     
            if (!resize) {
                // 移动窗口
                newFrame.origin.x += delta.x;
                newFrame.origin.y += delta.y;
     
            } else {
                NSSize maxSize = [window maxSize];
                NSSize minSize = [window minSize];
     
                // 改变窗口大小
                newFrame.size.width += delta.x;
                newFrame.size.height -= delta.y;
     
                // 控制窗口大小在限制范围内
                newFrame.size.width = MIN(MAX(newFrame.size.width, minSize.width), maxSize.width);
                newFrame.size.height = MIN(MAX(newFrame.size.height, minSize.height), maxSize.height);
                newFrame.origin.y -= newFrame.size.height - originalFrame.size.height;
            }
     
            [window setFrame:newFrame display:YES animate:NO];
        }
    }
     
    @end

    以上代码借鉴自 RoundWindow 示例代码。

    这些代码就实现了点击窗口就可以拖动窗口,点击 resizeAreaRect 所返回的区域就可以改变窗口大小。

    但是在用的时候,会发现一个问题,如果这个窗口上有控件的话,例如有按钮、文本框等控件,那么就鼠标点击在按钮上时,就无法拖动这个窗口了。

    有些时候可能整个窗口是一个列表,没有空余的地方露出 Window 本身,那想要拖动窗口就会变得很麻烦了。

    实现任意位置移动

    因为 NSWindow 的 mouseDown: 方法,只会在点击在窗口本身上时,才会被调用,而如果点击在了按钮或者文本框上,这个鼠标事件就不会传递到 NSWindow 上。

    根据《Cocoa Event Handling Guide》可以知道,如果有事件过来,会由 NSApp 分发到 NSWindow,再通过 NSWindow,使用 sendEvent: 方法来分发,那么我们就可以在个方法中,去捕获鼠标点击事件。

    Path of a mouse event
    via Cocoa Event Handling Guide

    //
    //  JWBorderlessWindow
    //
    //  Created by Xu Jiwei on 12-9-3.
    //  Copyright (c) 2012 xujiwei.com. All rights reserved.
    //
     
    @interface JWBorderlessWindow : NSWindow {
        BOOL            mouseDraggedForMoveOrResize;
        BOOL            mouseDownInResizeArea;
        NSPoint         mouseDownLocation;
        NSRect          mouseDownWindowFrame;
    }
    @end
     
    @implementation JWBorderlessWindow
     
    - (BOOL)canBecomeKeyWindow {
        return YES;
    }
     
     
    - (BOOL)canBecomeMainWindow {
        return YES;
    }
     
     
    - (NSRect)resizeAreaRect {
        const CGFloat resizeBoxSize = 20.0;
     
        NSRect frame = [self frame];
        NSRect resizeRect = NSMakeRect(frame.size.width - resizeBoxSize, 0,
                                       resizeBoxSize, resizeBoxSize);
     
        return resizeRect;
    }
     
     
    - (void)handleMoveOrResize:(NSEvent *)event {
        NSWindow *window = self;
     
        //
        // Work out how much the mouse has moved
        //
        NSPoint newMouseLocation = [window convertBaseToScreen:[event locationInWindow]];
        NSPoint delta = NSMakePoint(newMouseLocation.x - mouseDownLocation.x,
                                    newMouseLocation.y - mouseDownLocation.y);
     
        NSRect newFrame = mouseDownWindowFrame;
     
        if (!mouseDownInResizeArea) {
            //
            // Alter the frame for a drag
            //
            newFrame.origin.x += delta.x;
            newFrame.origin.y += delta.y;
     
        } else {
            NSSize maxSize = [window maxSize];
            NSSize minSize = [window minSize];
     
            newFrame.size.width += delta.x;
            newFrame.size.height -= delta.y;
     
            newFrame.size.width = MIN(MAX(newFrame.size.width, minSize.width), maxSize.width);
            newFrame.size.height = MIN(MAX(newFrame.size.height, minSize.height), maxSize.height);
            newFrame.origin.y -= newFrame.size.height - mouseDownWindowFrame.size.height;
        }
     
        [window setFrame:newFrame display:YES animate:NO];
    }
     
     
    - (void)sendEvent:(NSEvent *)event {
        // 处理单击事件,实现在窗口任意位置移动窗口
        if (event.type == NSLeftMouseDown) {
            mouseDraggedForMoveOrResize = NO;
            mouseDownLocation = [self convertBaseToScreen:[event locationInWindow]];
            mouseDownWindowFrame = [self frame];
            mouseDownInResizeArea = NSPointInRect([event locationInWindow], [self resizeAreaRect]);
     
            BOOL keepOn = YES;
     
            while (keepOn) {
                NSEvent *newEvent = [self nextEventMatchingMask:(NSLeftMouseDraggedMask | NSLeftMouseUpMask)
                                                      untilDate:[NSDate distantFuture]
                                                         inMode:NSEventTrackingRunLoopMode
                                                        dequeue:NO];
                switch (newEvent.type) {
                    case NSLeftMouseDragged:
                        // 处理鼠标移动事件
                        [self handleMoveOrResize:newEvent];
                        // 把事件从队列中删除
                        [self nextEventMatchingMask:(NSLeftMouseDraggedMask | NSLeftMouseUpMask)];
                        mouseDraggedForMoveOrResize = YES;
                        break;
     
                    case NSLeftMouseUp:
                        // 如果不是正在移动窗口或改变窗口大小,就把事件继续分发
                        if (!mouseDraggedForMoveOrResize) {
                            [super sendEvent:event];
                        }
                        keepOn = NO;
                        break;
     
                    default:
                        keepOn = NO;
                        break;
                }
            }
     
        } else {
            [super sendEvent:event];
        }
    }
     
    @end

    这时候又会有另外一个问题,虽然可以在窗口任意位置都可以拖动窗口,但是在 NSTextField 或者 NSTextView 获取焦点时,如果想用鼠标拖动光标来选择文本,是没办法做到的,在拖动的时候变成了在拖动整个窗口。

    好吧,继续解决问题。

    修正 NSTextField 及 NSTextView 兼容性

    - (void)sendEvent:(NSEvent *)event {
        // 处理单击事件,实现在窗口任意位置移动窗口
        // 判断鼠标点击所在位置的 view,如果是 NSTextView,就不处理,直接继续传递事件
        NSView *targetView = [self.contentView hitTest:[event locationInWindow]];
        if (event.type == NSLeftMouseDown && ![targetView isKindOfClass:[NSTextView class]]) {
            // 省略,与前一段代码相同
     
        } else {
            [super sendEvent:event];
        }
    }

    完成,在 NSTextField 或者 NSTextView 获得焦点时,可以用鼠标来拖动选中内容了。

    结语

    下载文中示例程序:JWBorderlessWindowTest (39KB)

    参考资料

    1. [http: //cocoawithlove.com/2008/12/drawing-custom-window-on-mac-os-x.html](http: //cocoawithlove.com/2008/12/drawing-custom-window-on-mac-os-x.html)
    2. Cocoa Event Handling Guide: Event Architecture – Event Dispatch

    — EOF —