├── .gitignore ├── LICENSE ├── README.md ├── res └── icons │ ├── GitHub-Mark-120px-plus.png │ ├── GitHub-Mark-32px.png │ ├── GitHub-Mark-64px.png │ ├── GitHub-Mark-Light-120px-plus.png │ ├── GitHub-Mark-Light-32px.png │ └── GitHub-Mark-Light-64px.png ├── shard.lock ├── shard.yml └── src └── github_desktop_notifications.cr /.gitignore: -------------------------------------------------------------------------------- 1 | .shards/ 2 | lib/ 3 | .crystal/ 4 | bin/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Jonne Haß 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Github desktop notifications 2 | 3 | 4 | Optimized for Gnome, but should be working with any DE that supports 5 | libnotify. 6 | 7 | #### Dependencies 8 | 9 | * [Crystal](http://crystal-lang.org) 10 | * libnotify 11 | * Gtk 12 | 13 | 14 | #### Setup 15 | 16 | ```bash 17 | crystal deps 18 | ``` 19 | 20 | 21 | #### Compile & run 22 | 23 | ```bash 24 | crystal src/github_desktop_notifications.cr 25 | ``` 26 | -------------------------------------------------------------------------------- /res/icons/GitHub-Mark-120px-plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhass/github_desktop_notifications/adabc76e82977359a79131de43f09a728f932c98/res/icons/GitHub-Mark-120px-plus.png -------------------------------------------------------------------------------- /res/icons/GitHub-Mark-32px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhass/github_desktop_notifications/adabc76e82977359a79131de43f09a728f932c98/res/icons/GitHub-Mark-32px.png -------------------------------------------------------------------------------- /res/icons/GitHub-Mark-64px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhass/github_desktop_notifications/adabc76e82977359a79131de43f09a728f932c98/res/icons/GitHub-Mark-64px.png -------------------------------------------------------------------------------- /res/icons/GitHub-Mark-Light-120px-plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhass/github_desktop_notifications/adabc76e82977359a79131de43f09a728f932c98/res/icons/GitHub-Mark-Light-120px-plus.png -------------------------------------------------------------------------------- /res/icons/GitHub-Mark-Light-32px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhass/github_desktop_notifications/adabc76e82977359a79131de43f09a728f932c98/res/icons/GitHub-Mark-Light-32px.png -------------------------------------------------------------------------------- /res/icons/GitHub-Mark-Light-64px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhass/github_desktop_notifications/adabc76e82977359a79131de43f09a728f932c98/res/icons/GitHub-Mark-Light-64px.png -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 1.0 2 | shards: 3 | gobject: 4 | github: jhass/crystal-gobject 5 | version: 0.6.0 6 | 7 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: github_desktop_notifications 2 | version: 0.1.0 3 | authors: 4 | - "Jonne Haß " 5 | description: "Libnotify based desktop notifications for Github" 6 | license: Apache-2.0 7 | crystal: 0.34.0 8 | targets: 9 | github_desktop_notifications: 10 | main: src/github_desktop_notifications.cr 11 | dependencies: 12 | gobject: 13 | github: "jhass/crystal-gobject" 14 | version: ~> 0.6.0 -------------------------------------------------------------------------------- /src/github_desktop_notifications.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "http/client" 3 | require "uri" 4 | 5 | require "gobject/notify" 6 | 7 | MAIN_LOOP = GLib::MainLoop.new(nil, true) 8 | LibNotify.init("Github") 9 | 10 | lib LibC 11 | fun getpass(prompt : UInt8*) : UInt8* 12 | fun gethostname(name : UInt8*, len : SizeT) : Int32 13 | end 14 | 15 | class InfoError < Exception 16 | include SystemError 17 | end 18 | 19 | def gethostname : String 20 | String.new(255) do |buffer| 21 | unless LibC.gethostname(buffer, 255u64) == 0 22 | raise InfoError.from_errno("Could not get hostname") 23 | end 24 | len = LibC.strlen(buffer) 25 | {len, len} 26 | end 27 | end 28 | 29 | module GithubDesktopNotifications 30 | VERSION = "0.1.0" 31 | 32 | class Config 33 | XDG_CONFIG_HOME = ENV["XDG_CONFIG_HOME"]? || File.expand_path("~/.config", home: true) 34 | PATH = File.join(XDG_CONFIG_HOME, "github_desktop_notifications") 35 | 36 | JSON.mapping({ 37 | user: String, 38 | token: String, 39 | }, true) 40 | 41 | def initialize(@user, @token) 42 | end 43 | 44 | def self.find_or_create 45 | unless File.exists? PATH 46 | fetcher = TokenFetcher.new 47 | 48 | config = Config.new(fetcher.user, fetcher.token) 49 | 50 | File.write PATH, config.to_json 51 | 52 | config 53 | else 54 | from_json File.read(PATH) 55 | end 56 | end 57 | 58 | def self.invalidate! 59 | STDERR.puts "Expired credentials, please rerun!" 60 | File.delete(PATH) if File.exists? PATH 61 | exit 1 62 | end 63 | end 64 | 65 | class Client 66 | class IdType 67 | def self.from_json(pull : JSON::PullParser) 68 | case pull.kind 69 | when .string? 70 | pull.read_string.to_i64 71 | when .int? 72 | pull.read_int 73 | else 74 | raise "Expected string or int but was #{pull.kind}" 75 | end 76 | end 77 | end 78 | 79 | class Authorization 80 | JSON.mapping({ 81 | id: {type: Int64, converter: IdType}, 82 | note: {type: String, nilable: true}, 83 | note_url: {type: String, nilable: true}, 84 | token: String, 85 | }) 86 | 87 | def self.create(client, params) 88 | from_json client.post("authorizations", params).body 89 | end 90 | 91 | def self.list(client) 92 | Array(Authorization).from_json client.get("authorizations").body 93 | end 94 | end 95 | 96 | class User 97 | JSON.mapping({ 98 | id: {type: Int64, converter: IdType}, 99 | login: String, 100 | }) 101 | end 102 | 103 | class Repository 104 | JSON.mapping({ 105 | id: {type: Int64, converter: IdType}, 106 | name: String, 107 | owner: User, 108 | }) 109 | end 110 | 111 | class Event 112 | JSON.mapping({ 113 | event: String, 114 | }) 115 | end 116 | 117 | class Issue 118 | JSON.mapping({ 119 | id: {type: Int64, converter: IdType}, 120 | events_url: String, 121 | }) 122 | 123 | def events 124 | uri = URI.parse events_url 125 | Array(Event).from_json Client.get(uri.path).body 126 | end 127 | 128 | def self.from_url(url) 129 | uri = URI.parse url 130 | from_json Client.get(uri.path.not_nil!).body 131 | end 132 | end 133 | 134 | class Comment 135 | JSON.mapping({ 136 | id: {type: Int64, converter: IdType}, 137 | html_url: String, 138 | body: String, 139 | }) 140 | 141 | def self.from_url(url) 142 | uri = URI.parse url 143 | from_json Client.get(uri.path.not_nil!).body 144 | end 145 | end 146 | 147 | class Release 148 | JSON.mapping({ 149 | id: {type: Int64, converter: IdType}, 150 | html_url: String, 151 | }) 152 | 153 | def self.from_url(url) 154 | uri = URI.parse url 155 | from_json Client.get(uri.path.not_nil!).body 156 | end 157 | end 158 | 159 | class Notification 160 | class Subject 161 | JSON.mapping({ 162 | title: String, 163 | url: String, 164 | latest_comment_url: String?, 165 | type: String, 166 | }) 167 | end 168 | 169 | JSON.mapping({ 170 | id: {type: Int64, converter: IdType}, 171 | repository: Repository, 172 | subject: Subject, 173 | }) 174 | 175 | def self.poll(opts = {} of Symbol | String => String | Bool | Int32, &block : Array(Notification) ->) 176 | Client.poll(->(headers : Hash(String, String)) { Client.get "notifications", opts, headers: headers }) do |response| 177 | begin 178 | block.call Array(Notification).from_json(response.body) 179 | rescue e : JSON::ParseException 180 | puts "Failed to parse: #{response.body}" 181 | raise e 182 | end 183 | end 184 | end 185 | 186 | def html_url 187 | case subject.type 188 | when "Issue", "PullRequest", "Commit" 189 | if comment_url = subject.latest_comment_url 190 | Comment.from_url(comment_url).html_url 191 | end 192 | when "Release" 193 | Release.from_url(subject.url).html_url 194 | else 195 | raise "Not yet implemented for #{subject.type}" 196 | end 197 | rescue e : Error # ignore failing to fetch nested resources for whatever reason 198 | puts "Warning: Failed to fetch subject: #{e.message}" 199 | nil 200 | end 201 | 202 | def title 203 | "#{repository.owner.login}/#{repository.name} - #{subject.title}" 204 | end 205 | end 206 | 207 | class Error < Exception 208 | class Response 209 | JSON.mapping({ 210 | message: String, 211 | }) 212 | end 213 | 214 | getter headers : HTTP::Headers 215 | getter status_code : Int32 216 | 217 | def initialize(message, @headers, @status_code) 218 | super "Got response with code #{@status_code}: #{message}" 219 | end 220 | 221 | def self.from_response(path, response) 222 | new "While requesting #{path}: #{Response.from_json(response.body).message}", 223 | response.headers, response.status_code 224 | end 225 | end 226 | 227 | OAUTH_PASSWORD = "x-oauth-basic" 228 | 229 | def self.instance 230 | unless @@instance 231 | config = GithubDesktopNotifications::Config.find_or_create 232 | @@instance = GithubDesktopNotifications::Client.new config.token, OAUTH_PASSWORD 233 | end 234 | 235 | @@instance.not_nil! 236 | end 237 | 238 | def self.poll(request, &block : HTTP::Client::Response ->) 239 | instance.poll(request, &block) 240 | end 241 | 242 | def self.get(endpoint, params = nil, headers = nil) 243 | instance.get endpoint, params, headers 244 | end 245 | 246 | def self.post(endpoint, payload, headers = nil) 247 | instance.post endpoint, payload, headers 248 | end 249 | 250 | @client : HTTP::Client? 251 | 252 | def initialize(@user : String, @password : String, @otp_token : String? = nil) 253 | @client = client 254 | end 255 | 256 | # Stdlib bug: 257 | # Reusing the client for another request in an SSL session is broken, 258 | # apparently 259 | private def client 260 | close 261 | client = HTTP::Client.new("api.github.com", tls: true) 262 | client.basic_auth @user, @password 263 | @client = client 264 | end 265 | 266 | def poll(request : Hash(String, String) -> HTTP::Client::Response, &block : HTTP::Client::Response ->) 267 | headers = {} of String => String 268 | GLib.idle_add do 269 | run_poll(headers, request, block) 270 | end 271 | end 272 | 273 | def run_poll(headers, request : Hash(String, String) -> HTTP::Client::Response, callback : HTTP::Client::Response ->) 274 | response = request.call(headers) 275 | 276 | if response.status_code == 200 277 | callback.call response 278 | 279 | if response.headers["Last-Modified"]? 280 | headers["If-Modified-Since"] = response.headers["Last-Modified"] 281 | end 282 | end 283 | 284 | timeout = {(response.headers["X-Poll-Interval"]? || 0).to_i, 30}.max 285 | GLib.timeout(timeout) do 286 | run_poll(headers, request, callback) 287 | end 288 | 289 | false 290 | # Ignore timeouts, no network, unexpected responses and such 291 | 292 | 293 | rescue e : IO::Error | Socket::Error | JSON::ParseException | Error 294 | puts "Warning: Got #{e.class}: #{e.message}" 295 | true 296 | rescue e # Workaround 'Could not raise' 297 | puts "#{e.class}: #{e.message}" 298 | puts e.backtrace.join("\n") 299 | MAIN_LOOP.quit 300 | false 301 | end 302 | 303 | def get(endpoint, params = nil, headers = nil) 304 | params ||= {} of Symbol | String => String 305 | query_string = params.map { |key, value| "#{key}=#{URI.encode(value.to_s)}" }.join('&') 306 | 307 | perform("#{normalize_endpoint(endpoint)}?#{query_string}") { |path| 308 | client.get(path, build_headers(headers)) 309 | } 310 | end 311 | 312 | def post(endpoint, payload, headers = nil) 313 | perform(normalize_endpoint(endpoint)) { |path| 314 | client.post(path, build_headers(headers), payload.to_json) 315 | } 316 | end 317 | 318 | private def normalize_endpoint(endpoint) 319 | endpoint.starts_with?('/') ? endpoint : "/#{endpoint}" 320 | end 321 | 322 | private def perform(path, &request : String -> HTTP::Client::Response) 323 | response = request.call path 324 | 325 | if 301 <= response.status_code <= 302 326 | perform response.headers["Location"], &request 327 | elsif 200 <= response.status_code < 400 328 | response 329 | else 330 | Config.invalidate! if @password == OAUTH_PASSWORD && response.status_code == 401 331 | raise Error.from_response(path, response) 332 | end 333 | end 334 | 335 | private def build_headers(additional = nil) 336 | HTTP::Headers{ 337 | "Accept" => ["application/vnd.github.v3+json"], 338 | "User-Agent" => ["github_desktop_notifications"], 339 | }.tap do |headers| 340 | otp_token = @otp_token 341 | headers["X-GitHub-OTP"] = [otp_token] if otp_token 342 | if additional 343 | additional.each do |key, value| 344 | headers.add key, value 345 | end 346 | end 347 | end 348 | end 349 | 350 | def close 351 | client = @client 352 | client.close if client 353 | end 354 | 355 | def finalize 356 | close 357 | end 358 | end 359 | 360 | class TokenFetcher 361 | NOTE = "Github desktop notifications (%s)" 362 | NOTE_URL = "http://github.com/jhass/github_desktop_notifications" 363 | REQUESTED_SCOPES = %w(notifications) 364 | 365 | getter user : String 366 | getter token : String 367 | @identifer : String 368 | @password : String 369 | @otp_token : String? 370 | 371 | def initialize 372 | @identifer = gethostname 373 | @user, @password = read_credentials 374 | @token = fetch 375 | end 376 | 377 | private def read_credentials 378 | user = prompt("Github user: ") 379 | password = password_prompt("Password: ") 380 | {user, password} 381 | end 382 | 383 | private def prompt(prompt) 384 | print prompt 385 | gets.not_nil!.chomp 386 | end 387 | 388 | private def password_prompt(prompt) 389 | password = String.new(LibC.getpass(prompt)).chomp 390 | end 391 | 392 | private def fetch 393 | client = Client.new @user, @password, @otp_token 394 | 395 | Client::Authorization.create(client, { 396 | note: NOTE % @identifer, 397 | note_url: NOTE_URL, 398 | scopes: REQUESTED_SCOPES, 399 | fingerprint: @identifer, 400 | }).token 401 | rescue e : Client::Error 402 | if e.headers["X-GitHub-OTP"]? 403 | puts e.message 404 | 405 | @otp_token = prompt("OTP token: ") 406 | elsif e.status_code == 422 407 | @identifer = next_identifier 408 | elsif 400 <= e.status_code <= 499 409 | puts e.message 410 | 411 | @user, @password = read_credentials 412 | else 413 | raise e 414 | end 415 | 416 | fetch 417 | end 418 | 419 | private def next_identifier 420 | digit = @identifer.match /\-(\d)$/ 421 | if digit 422 | digit = digit[1].to_i 423 | @identifer.gsub(/\-\d$/, "-#{digit + 1}") 424 | else 425 | "#{@identifer}-1" 426 | end 427 | end 428 | end 429 | 430 | class Notification 431 | NOTIFICATIONS_URL = "https://github.com/notifications" 432 | 433 | getter url 434 | private getter! notification : Notify::Notification? 435 | 436 | def initialize 437 | @url = NOTIFICATIONS_URL 438 | @active = false 439 | @used = true 440 | @shown = false 441 | end 442 | 443 | private def build 444 | this = self 445 | @active = false 446 | @used = false 447 | @shown = false 448 | 449 | @notification = Notify::Notification.build do |n| 450 | n.summary = "Github" 451 | n.urgency = :low 452 | n.icon_name = icon_path 453 | 454 | action "default", "Show" do 455 | this.launch_browser 456 | end 457 | 458 | action "show", "Show" do 459 | this.launch_browser 460 | end 461 | end 462 | 463 | notification.on_closed do 464 | this.closed 465 | end 466 | end 467 | 468 | def launch_browser 469 | Gio::AppInfo.launch_default_for_uri url, nil unless @shown 470 | @shown = true 471 | end 472 | 473 | private def icon_path 474 | path = File.expand_path("../res/icons/GitHub-Mark-Light-64px.png", File.dirname(__FILE__)) 475 | File.exists?(path) ? path : "github_desktop_notifications" 476 | end 477 | 478 | def closed 479 | @used = true 480 | @active = false 481 | end 482 | 483 | def update(notifications) 484 | notifications = notifications.select &.html_url 485 | return if notifications.empty? 486 | 487 | notification_lines = notifications.map &.title 488 | 489 | if @active 490 | notification_lines = (notification_lines + notification.body.to_s.lines).uniq 491 | end 492 | 493 | if notification_lines.size > 1 494 | @url = NOTIFICATIONS_URL 495 | else 496 | @url = notifications.first.html_url.not_nil! 497 | end 498 | 499 | if @used 500 | build 501 | end 502 | 503 | @active = true 504 | notification.body = notification_lines.join("\n") 505 | notification.show 506 | end 507 | end 508 | end 509 | 510 | notification = GithubDesktopNotifications::Notification.new 511 | GithubDesktopNotifications::Client::Notification.poll do |notifications| 512 | notification.update notifications 513 | end 514 | 515 | MAIN_LOOP.run 516 | --------------------------------------------------------------------------------