echoes 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Echoes
6
+ # Puts a thin wrapper Echoes.app / EchoesEmbed.app into
7
+ # ~/Applications/ so the user can launch echoes from Spotlight,
8
+ # Dock, or Cmd-Space without digging into the gem's install path.
9
+ # The wrapper's only job is to exec into the *real* launcher
10
+ # inside the gem — the gem's launcher already does all the
11
+ # interesting work (arch forcing on Apple Silicon, Ruby probing,
12
+ # $LOAD_PATH bootstrap).
13
+ #
14
+ # The gem path is baked into the wrapper at install time. After
15
+ # `gem update echoes` the user should re-run `echoes install` to
16
+ # refresh the shortcut to the new gem location.
17
+ module Installer
18
+ USER_APPS_DIR = File.expand_path('~/Applications')
19
+ BUNDLES = %w[Echoes.app EchoesEmbed.app].freeze
20
+
21
+ module_function
22
+
23
+ # `source_root` is the directory containing the .app bundles —
24
+ # normally the gem's own root, computed from this file's path.
25
+ # Overridable for tests.
26
+ def install(source_root: default_source_root, target_dir: USER_APPS_DIR)
27
+ FileUtils.mkdir_p(target_dir)
28
+
29
+ installed = []
30
+ BUNDLES.each do |bundle|
31
+ source = File.join(source_root, bundle)
32
+ unless Dir.exist?(source)
33
+ warn "echoes install: #{bundle} not found at #{source} (skipping)"
34
+ next
35
+ end
36
+ target = File.join(target_dir, bundle)
37
+ write_wrapper(source, target)
38
+ installed << target
39
+ end
40
+
41
+ if installed.empty?
42
+ puts 'echoes install: nothing to do.'
43
+ else
44
+ puts 'Installed:'
45
+ installed.each { |t| puts " #{t}" }
46
+ puts
47
+ puts 'Re-run `echoes install` after `gem update echoes` to refresh the shortcuts.'
48
+ end
49
+ installed
50
+ end
51
+
52
+ def uninstall(target_dir: USER_APPS_DIR)
53
+ removed = []
54
+ BUNDLES.each do |bundle|
55
+ target = File.join(target_dir, bundle)
56
+ next unless Dir.exist?(target)
57
+ FileUtils.remove_entry(target)
58
+ removed << target
59
+ end
60
+ if removed.empty?
61
+ puts 'echoes uninstall: nothing to remove.'
62
+ else
63
+ removed.each { |t| puts "Removed #{t}" }
64
+ end
65
+ removed
66
+ end
67
+
68
+ def default_source_root
69
+ # File is at <gem-root>/lib/echoes/installer.rb; two `..`s land
70
+ # at the gem root where Echoes.app / EchoesEmbed.app live.
71
+ File.expand_path('../..', __dir__)
72
+ end
73
+
74
+ def write_wrapper(source_bundle, target_bundle)
75
+ bundle_name = File.basename(target_bundle)
76
+ executable = bundle_name.delete_suffix('.app')
77
+
78
+ FileUtils.mkdir_p(File.join(target_bundle, 'Contents', 'MacOS'))
79
+
80
+ source_plist = File.join(source_bundle, 'Contents', 'Info.plist')
81
+ if File.exist?(source_plist)
82
+ FileUtils.cp(source_plist, File.join(target_bundle, 'Contents', 'Info.plist'))
83
+ end
84
+
85
+ script_path = File.join(target_bundle, 'Contents', 'MacOS', executable)
86
+ File.write(script_path, <<~SHELL)
87
+ #!/bin/bash
88
+ # Auto-generated by `echoes install`. Re-run after `gem update
89
+ # echoes` so this points at the current installed location.
90
+ exec "#{source_bundle}/Contents/MacOS/#{executable}"
91
+ SHELL
92
+ File.chmod(0o755, script_path)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fiddle'
4
+
5
+ module Echoes
6
+ module ObjC
7
+ LIBOBJC = Fiddle.dlopen('/usr/lib/libobjc.A.dylib')
8
+ APPKIT = Fiddle.dlopen('/System/Library/Frameworks/AppKit.framework/AppKit')
9
+ FOUNDATION = Fiddle.dlopen('/System/Library/Frameworks/Foundation.framework/Foundation')
10
+
11
+ # Type aliases
12
+ P = Fiddle::TYPE_VOIDP
13
+ D = Fiddle::TYPE_DOUBLE
14
+ L = Fiddle::TYPE_LONG
15
+ I = Fiddle::TYPE_INT
16
+ V = Fiddle::TYPE_VOID
17
+
18
+ # Core runtime functions
19
+ GetClass = Fiddle::Function.new(LIBOBJC['objc_getClass'], [P], P)
20
+ RegisterName = Fiddle::Function.new(LIBOBJC['sel_registerName'], [P], P)
21
+ AllocateClassPair = Fiddle::Function.new(LIBOBJC['objc_allocateClassPair'], [P, P, I], P)
22
+ AddMethod = Fiddle::Function.new(LIBOBJC['class_addMethod'], [P, P, P, P], I)
23
+ RegisterClassPair = Fiddle::Function.new(LIBOBJC['objc_registerClassPair'], [P], V)
24
+ GetMethodImpl = Fiddle::Function.new(LIBOBJC['class_getMethodImplementation'], [P, P], P)
25
+ AddProtocol = Fiddle::Function.new(LIBOBJC['class_addProtocol'], [P, P], I)
26
+ GetProtocol = Fiddle::Function.new(LIBOBJC['objc_getProtocol'], [P], P)
27
+
28
+ # objc_msgSend variants for different signatures
29
+ def self.new_msg(args, ret)
30
+ Fiddle::Function.new(LIBOBJC['objc_msgSend'], args, ret)
31
+ end
32
+
33
+ MSG_PTR = new_msg([P, P], P) # id = msg(id, SEL)
34
+ MSG_PTR_1 = new_msg([P, P, P], P) # id = msg(id, SEL, id)
35
+ MSG_PTR_2 = new_msg([P, P, P, P], P) # id = msg(id, SEL, id, id)
36
+ MSG_PTR_L = new_msg([P, P, L], P) # id = msg(id, SEL, long)
37
+ MSG_PTR_1L = new_msg([P, P, P, L], P) # id = msg(id, SEL, id, long)
38
+ MSG_VOID = new_msg([P, P], V) # void = msg(id, SEL)
39
+ MSG_VOID_1 = new_msg([P, P, P], V) # void = msg(id, SEL, id)
40
+ MSG_VOID_2 = new_msg([P, P, P, P], V) # void = msg(id, SEL, id, id)
41
+ MSG_VOID_4 = new_msg([P, P, P, P, P, P], V) # void = msg(id, SEL, id, id, id, id)
42
+ MSG_PTR_3 = new_msg([P, P, P, P, P], P) # id = msg(id, SEL, id, id, id)
43
+ MSG_VOID_I = new_msg([P, P, I], V) # void = msg(id, SEL, int)
44
+ MSG_VOID_L = new_msg([P, P, L], V) # void = msg(id, SEL, long)
45
+ MSG_VOID_2D = new_msg([P, P, D, D], V) # void = msg(id, SEL, double, double)
46
+ MSG_RET_D = new_msg([P, P], D) # double = msg(id, SEL)
47
+ MSG_PTR_D = new_msg([P, P, D], P) # id = msg(id, SEL, double)
48
+ MSG_RET_D_1 = new_msg([P, P, P], D) # double = msg(id, SEL, id)
49
+ MSG_RET_L = new_msg([P, P], L) # long = msg(id, SEL)
50
+
51
+ # CGRect as 4 doubles
52
+ MSG_PTR_RECT = new_msg([P, P, D, D, D, D], P) # initWithFrame:
53
+ MSG_VOID_RECT = new_msg([P, P, D, D, D, D], V) # NSRectFill equivalent
54
+ MSG_VOID_RECT_1 = new_msg([P, P, D, D, D, D, P], V) # addCursorRect:cursor:
55
+ # NSGradient drawInRect:angle: (4 doubles for rect + 1 double for angle)
56
+ MSG_VOID_RECT_D = new_msg([P, P, D, D, D, D, D], V)
57
+
58
+ # initWithContentRect:styleMask:backing:defer:
59
+ MSG_PTR_RECT_L_L_I = new_msg([P, P, D, D, D, D, L, L, I], P)
60
+
61
+ # drawAtPoint:withAttributes: (NSPoint = 2 doubles + id)
62
+ MSG_VOID_PT_1 = new_msg([P, P, D, D, P], V)
63
+
64
+ # popUpMenuPositioningItem:atLocation:inView: (id + NSPoint + id)
65
+ MSG_VOID_1_PT_1 = new_msg([P, P, P, D, D, P], V)
66
+
67
+ # setDouble:forKey: (double + id) -> void
68
+ MSG_VOID_D_1 = new_msg([P, P, D, P], V)
69
+
70
+ # colorWithRed:green:blue:alpha: (4 doubles)
71
+ MSG_PTR_4D = new_msg([P, P, D, D, D, D], P)
72
+
73
+ # fontWithName:size: (id, double)
74
+ MSG_PTR_1D = new_msg([P, P, P, D], P)
75
+
76
+ # monospacedSystemFontOfSize:weight: (2 doubles)
77
+ MSG_PTR_2D = new_msg([P, P, D, D], P)
78
+
79
+ # scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:
80
+ MSG_PTR_D_P_P_P_I = new_msg([P, P, D, P, P, P, I], P)
81
+
82
+ # NSRectFill C function
83
+ NSRectFill = Fiddle::Function.new(APPKIT['NSRectFill'], [D, D, D, D], V)
84
+
85
+ # Cocoa constants
86
+ NSWindowStyleMaskTitled = 1 << 0
87
+ NSWindowStyleMaskClosable = 1 << 1
88
+ NSWindowStyleMaskMiniaturizable = 1 << 2
89
+ NSWindowStyleMaskResizable = 1 << 3
90
+ NSWindowStyleMaskDefault = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable |
91
+ NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable
92
+ NSBackingStoreBuffered = 2
93
+
94
+ NSEventModifierFlagShift = 1 << 17
95
+ NSEventModifierFlagControl = 1 << 18
96
+ NSEventModifierFlagOption = 1 << 19
97
+ NSEventModifierFlagCommand = 1 << 20
98
+ NSEventModifierFlagNumericPad = 1 << 21
99
+
100
+ # Selector cache
101
+ SEL_CACHE = {}
102
+
103
+ def self.cls(name)
104
+ GetClass.call(name)
105
+ end
106
+
107
+ def self.sel(name)
108
+ SEL_CACHE[name] ||= RegisterName.call(name)
109
+ end
110
+
111
+ def self.retain(obj)
112
+ MSG_PTR.call(obj, sel('retain'))
113
+ end
114
+
115
+ def self.release(obj)
116
+ MSG_VOID.call(obj, sel('release'))
117
+ end
118
+
119
+ def self.nsstring(str)
120
+ MSG_PTR_1.call(cls('NSString'), sel('stringWithUTF8String:'), str)
121
+ end
122
+
123
+ def self.to_ruby_string(nsstring_ptr)
124
+ cstr = MSG_PTR.call(nsstring_ptr, sel('UTF8String'))
125
+ cstr.to_s.force_encoding(Encoding::UTF_8)
126
+ end
127
+
128
+ def self.nsdict(hash)
129
+ dict = MSG_PTR.call(cls('NSMutableDictionary'), sel('dictionary'))
130
+ hash.each do |key, value|
131
+ MSG_VOID_2.call(dict, sel('setObject:forKey:'), value, key)
132
+ end
133
+ dict
134
+ end
135
+
136
+ def self.nsnumber_int(val)
137
+ MSG_PTR_L.call(cls('NSNumber'), sel('numberWithInteger:'), val)
138
+ end
139
+
140
+ def self.define_class(name, superclass_name, methods)
141
+ super_cls = cls(superclass_name)
142
+ new_cls = AllocateClassPair.call(super_cls, name, 0)
143
+ methods.each do |sel_name, (type_encoding, closure)|
144
+ AddMethod.call(new_cls, sel(sel_name), closure, type_encoding)
145
+ end
146
+ RegisterClassPair.call(new_cls)
147
+ new_cls
148
+ end
149
+
150
+ # AppKit string constant accessors
151
+ def self.appkit_const(name)
152
+ ptr = Fiddle::Pointer.new(APPKIT[name])
153
+ Fiddle::Pointer.new(ptr[0, Fiddle::SIZEOF_VOIDP].unpack1('J'))
154
+ end
155
+
156
+ NSFontAttributeName = appkit_const('NSFontAttributeName')
157
+ NSForegroundColorAttributeName = appkit_const('NSForegroundColorAttributeName')
158
+ NSUnderlineStyleAttributeName = appkit_const('NSUnderlineStyleAttributeName')
159
+ NSStrikethroughStyleAttributeName = appkit_const('NSStrikethroughStyleAttributeName')
160
+ NSPasteboardTypeString = appkit_const('NSPasteboardTypeString')
161
+ NSPasteboardTypeFileURL = appkit_const('NSPasteboardTypeFileURL')
162
+
163
+ # CoreGraphics framework
164
+ COREGRAPHICS = Fiddle.dlopen('/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics')
165
+
166
+ CGColorSpaceCreateDeviceRGB = Fiddle::Function.new(COREGRAPHICS['CGColorSpaceCreateDeviceRGB'], [], P)
167
+ CGColorSpaceRelease = Fiddle::Function.new(COREGRAPHICS['CGColorSpaceRelease'], [P], V)
168
+ CGBitmapContextCreate = Fiddle::Function.new(COREGRAPHICS['CGBitmapContextCreate'], [P, L, L, L, L, P, I], P)
169
+ CGBitmapContextCreateImage = Fiddle::Function.new(COREGRAPHICS['CGBitmapContextCreateImage'], [P], P)
170
+ CGContextDrawImage = Fiddle::Function.new(COREGRAPHICS['CGContextDrawImage'], [P, D, D, D, D, P], V)
171
+ CGContextSaveGState = Fiddle::Function.new(COREGRAPHICS['CGContextSaveGState'], [P], V)
172
+ CGContextRestoreGState = Fiddle::Function.new(COREGRAPHICS['CGContextRestoreGState'], [P], V)
173
+ CGContextTranslateCTM = Fiddle::Function.new(COREGRAPHICS['CGContextTranslateCTM'], [P, D, D], V)
174
+ CGContextScaleCTM = Fiddle::Function.new(COREGRAPHICS['CGContextScaleCTM'], [P, D, D], V)
175
+ CGImageRelease = Fiddle::Function.new(COREGRAPHICS['CGImageRelease'], [P], V)
176
+ CGContextRelease = Fiddle::Function.new(COREGRAPHICS['CGContextRelease'], [P], V)
177
+
178
+ # kCGImageAlphaPremultipliedLast | kCGBitmapByteOrderDefault
179
+ KCGImageAlphaPremultipliedLast = 1
180
+
181
+ # CoreText framework
182
+ CORETEXT = Fiddle.dlopen('/System/Library/Frameworks/CoreText.framework/CoreText')
183
+
184
+ # CTFontCreateForString(CTFontRef currentFont, CFStringRef string, CFRange range) -> CTFontRef
185
+ # CFRange is {CFIndex, CFIndex} = {long, long}, decomposed into 2 GPR args on arm64
186
+ CTFontCreateForString = Fiddle::Function.new(CORETEXT['CTFontCreateForString'], [P, P, L, L], P)
187
+ end
188
+ end