@m_seki の

I like ruby tooから引っ越し

rqrを使った話

QRコードを使って、ちょっとしたメモをiPhoneに転送するためのアプリhitori-koto.rb。gitの使い方を本当に忘れたのでgistで。(でもgistのファイルの順が気に入らないのでふつうに貼った)

URLやちょっとした文(例えばtwitterに流したいようなもの)をネットワークを介さずにiPhoneに送りたいことがままあるので、QRコードで文字情報を交換するものを書いた。というか元ネタを考えてくれたのはtsu某i氏。テキストエディタ+QRコード表示みたいなものも作ってみたけど、iPhone側でそんなに長い文章を認識するのがしんどいことがわかってきたので、一行に特化することにした。独り用のタイムライン風の画面にQRコードを表示するだけ。タイムラインはクッキーによるセッション単位に保持されるので、他人の情報が混じることはありません。
実装側のおもしろさとしては、QRコードpngのデータをどこで持つか、誰が返すかっていうところなんだけど、Tofuで素朴にやったのであんまり苦労しなかった。
そうそう。rqrがメモリにQRコードを出力してくれない(あるいはやり方がわからない)ので泣く泣くTempfileを使っているのが悲しいところ。

誰も試さないと思うけど、必要なライブラリはRBTree, rqr, Tofu(and Div)です。Tofuはgitから持ってこれます。やり方思い出せないけど。

RBTreeのキーにTimeを使うことにした。tzを考えなくてよい場合、Timeを格納、復元するにはsecとusecの二つの整数があればいいのかしらん。

require 'div'
require 'tofu/tofu'
require 'tofu/tofulet'
require 'monitor'
require 'rbtree'
require 'enumerator'
require 'rqr'
require 'time'

class Store
  include MonitorMixin
  def initialize
    super()
    @tree = RBTree.new
    @enum = @tree.to_enum(:reverse_each)
  end
  attr_reader :tree

  def qr_at(time)
    _, qr = @tree[time]
    qr || NotFoundQR
  end

  def each_slice(n, &blk)
    synchronize do
      @enum.each_slice(n, &blk)
      nil
    end
  end

  def import_string(str)
    NKF.nkf('-sdXm0', str)
  end

  def self.qr_code(text)
    tmp = Tempfile.new('hitori_koto')
    RQR::QRCode.create do |qr|
      qr.save(text, tmp.path, :png)
    end
    tmp.open
    tmp.read
  ensure
    tmp.close(true) if tmp
  end

  def qr_code(text)
    self.class.qr_code(text)
  end

  def to_qr(text)
    qr_code(text)
  rescue RQR::EncodeException
    EncodeErrorQR
  end

  def add(str, context=nil)
    str = import_string(str)
    synchronize do
      key = Time.now
      latest, _ = @tree.last
      while latest == key
        key = Time.now
      end
      @tree[key] = [str, to_qr(str)]
      key
    end
  end

  NotFoundPNG = qr_code('not found')
  EncodeErrorPNG = qr_code('QR encode error')
end

class HitoriKotoSession < Div::TofuSession
  def initialize(bartender, hint=nil)
    super
    @content = Store.new
    @base = BaseDiv.new(self)
    @age = nil
  end
  attr_reader :content

  def do_qr_code(context)
    time = qr_path_to_time(context.req_path_info)
    return false unless time

    qr = @content.qr_at(time)
    context.res_header('expires', expires.httpdate)
    context.res_header('content-type', 'image/png; name="qrcode.png"')
    context.res_body(qr)
    return true
  end

  def qr_path(time)
    "qr" + h(time_to_key(time)) + ".png"
  end

  def qr_path_to_time(path)
    if /qr(.*)\.png$/ =~ path
      key_to_time($1)
    else
      nil
    end
  end

  def time_to_key(time)
    (time.to_i * 1000000 + time.usec).to_s(36)
  end

  def key_to_time(str)
    d = str.to_i(36)
    i, u = d.divmod(1000000)
    Time.at(i, u)
  rescue
    Time.now
  end

  def do_GET(context)
    update_div(context)
    
    return if do_qr_code(context)

    context.res_header('content-type', 'text/html; charset=Shift_JIS')
    context.res_body(@base.to_html(context))
  end
end

class BaseDiv < Div::Div
  set_erb('base.erb')
  
  def initialize(session)
    super(session)
    @enter = EnterDiv.new(session)
    @list = ListDiv.new(session)
  end
end

class EnterDiv < Div::Div
  set_erb('enter.erb')
  
  def do_enter(context, params)
    str ,= params['str']
    str = '(nil)' if (str.nil? || str.empty?)
    @session.content.add(str, context)
  end
end

class ListDiv < Div::Div
  set_erb('list.erb')

  def initialize(session)
    super(session)
    @content = session.content
  end

  def group_header(time)
    time.strftime("%H:%M")
  end
end

unless $DEBUG
  exit!(0) if fork
  Process.setsid
  exit!(0) if fork
end

uri = ARGV.shift || 'druby://localhost:54322'
tofu = Tofu::Bartender.new(HitoriKotoSession, 'hitori_' + uri.split(':').last)
DRb.start_service(uri, WEBrick::CGITofulet.new(tofu))

unless $DEBUG
  STDIN.reopen('/dev/null')
  STDOUT.reopen('/dev/null', 'w')
  STDERR.reopen('/dev/null', 'w')
end

DRb.thread.join