haltija 1.1.21 → 1.2.2

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,180 @@
1
+ /**
2
+ * Shared UI utilities — notifications, floating panels, drag tracking.
3
+ */
4
+
5
+ export function escapeHtml(str) {
6
+ if (!str) return ''
7
+ return str
8
+ .replace(/&/g, '&')
9
+ .replace(/</g, '&lt;')
10
+ .replace(/>/g, '&gt;')
11
+ .replace(/"/g, '&quot;')
12
+ }
13
+
14
+ /** Simple toast notification */
15
+ export function showNotification(message, duration = 2000) {
16
+ const existing = document.getElementById('toast-notification')
17
+ if (existing) existing.remove()
18
+
19
+ const toast = document.createElement('div')
20
+ toast.id = 'toast-notification'
21
+ toast.textContent = message
22
+ toast.style.cssText = `
23
+ position: fixed;
24
+ bottom: 20px;
25
+ left: 50%;
26
+ transform: translateX(-50%);
27
+ background: var(--accent, #6366f1);
28
+ color: white;
29
+ padding: 12px 24px;
30
+ border-radius: 8px;
31
+ font-size: 14px;
32
+ z-index: 10000;
33
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
34
+ animation: toast-in 0.2s ease-out;
35
+ `
36
+
37
+ if (!document.getElementById('toast-styles')) {
38
+ const style = document.createElement('style')
39
+ style.id = 'toast-styles'
40
+ style.textContent = `
41
+ @keyframes toast-in { from { opacity: 0; transform: translateX(-50%) translateY(10px); } }
42
+ @keyframes toast-out { to { opacity: 0; transform: translateX(-50%) translateY(10px); } }
43
+ `
44
+ document.head.appendChild(style)
45
+ }
46
+
47
+ document.body.appendChild(toast)
48
+
49
+ setTimeout(() => {
50
+ toast.style.animation = 'toast-out 0.2s ease-in forwards'
51
+ setTimeout(() => toast.remove(), 200)
52
+ }, duration)
53
+ }
54
+
55
+ /** Track a drag operation from mousedown/touchstart */
56
+ export function trackDrag(event, callback, cursor = 'move') {
57
+ const isTouchEvent = event.type.startsWith('touch')
58
+
59
+ if (!isTouchEvent) {
60
+ const origX = event.clientX
61
+ const origY = event.clientY
62
+
63
+ const tracker = document.createElement('div')
64
+ tracker.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:99999;cursor:' + cursor
65
+ document.body.appendChild(tracker)
66
+
67
+ const onMove = (e) => {
68
+ const dx = e.clientX - origX
69
+ const dy = e.clientY - origY
70
+ if (callback(dx, dy, e) === true) {
71
+ tracker.removeEventListener('mousemove', onMove)
72
+ tracker.removeEventListener('mouseup', onMove)
73
+ tracker.remove()
74
+ }
75
+ }
76
+
77
+ tracker.addEventListener('mousemove', onMove, { passive: true })
78
+ tracker.addEventListener('mouseup', onMove, { passive: true })
79
+ } else if (event.touches) {
80
+ const touch = event.touches[0]
81
+ const touchId = touch.identifier
82
+ const origX = touch.clientX
83
+ const origY = touch.clientY
84
+ const target = event.target
85
+
86
+ const onTouch = (e) => {
87
+ const t = [...e.touches].find(t => t.identifier === touchId)
88
+ const dx = t ? t.clientX - origX : 0
89
+ const dy = t ? t.clientY - origY : 0
90
+ if (callback(dx, dy, e) === true || !t) {
91
+ target.removeEventListener('touchmove', onTouch)
92
+ target.removeEventListener('touchend', onTouch)
93
+ target.removeEventListener('touchcancel', onTouch)
94
+ }
95
+ }
96
+
97
+ target.addEventListener('touchmove', onTouch)
98
+ target.addEventListener('touchend', onTouch, { passive: true })
99
+ target.addEventListener('touchcancel', onTouch, { passive: true })
100
+ }
101
+ }
102
+
103
+ function findHighestZ() {
104
+ return [...document.querySelectorAll('body *')]
105
+ .map(el => parseFloat(getComputedStyle(el).zIndex))
106
+ .filter(z => !isNaN(z))
107
+ .reduce((max, z) => Math.max(max, z), 0)
108
+ }
109
+
110
+ /** Create a floating panel positioned near a target element */
111
+ export function createFloatPanel({ target, content, title = '', position = 's', onClose }) {
112
+ const existing = document.querySelector(`.float-panel[data-title="${title}"]`)
113
+ if (existing) {
114
+ existing.remove()
115
+ return null
116
+ }
117
+
118
+ const panel = document.createElement('div')
119
+ panel.className = 'float-panel'
120
+ panel.dataset.title = title
121
+ panel.style.zIndex = findHighestZ() + 1
122
+
123
+ panel.innerHTML = `
124
+ <div class="float-header">
125
+ <span class="float-title">${escapeHtml(title)}</span>
126
+ <button class="float-close" title="Close">×</button>
127
+ </div>
128
+ <div class="float-content"></div>
129
+ `
130
+
131
+ panel.querySelector('.float-content').appendChild(content)
132
+
133
+ const header = panel.querySelector('.float-header')
134
+ header.addEventListener('mousedown', (e) => {
135
+ if (e.target.closest('.float-close')) return
136
+ panel.style.zIndex = findHighestZ() + 1
137
+ const x = panel.offsetLeft
138
+ const y = panel.offsetTop
139
+ trackDrag(e, (dx, dy, evt) => {
140
+ panel.style.left = `${x + dx}px`
141
+ panel.style.top = `${y + dy}px`
142
+ panel.style.right = 'auto'
143
+ panel.style.bottom = 'auto'
144
+ return evt.type === 'mouseup'
145
+ })
146
+ })
147
+
148
+ panel.querySelector('.float-close').addEventListener('click', () => {
149
+ panel.remove()
150
+ onClose?.()
151
+ })
152
+
153
+ document.body.appendChild(panel)
154
+
155
+ if (target) {
156
+ const rect = target.getBoundingClientRect()
157
+ const panelRect = panel.getBoundingClientRect()
158
+
159
+ let left, top
160
+ switch (position) {
161
+ case 'n':
162
+ left = rect.left + rect.width / 2 - panelRect.width / 2
163
+ top = rect.top - panelRect.height - 8
164
+ break
165
+ case 's':
166
+ default:
167
+ left = rect.left + rect.width / 2 - panelRect.width / 2
168
+ top = rect.bottom + 8
169
+ break
170
+ }
171
+
172
+ left = Math.max(8, Math.min(left, window.innerWidth - panelRect.width - 8))
173
+ top = Math.max(8, Math.min(top, window.innerHeight - panelRect.height - 8))
174
+
175
+ panel.style.left = `${left}px`
176
+ panel.style.top = `${top}px`
177
+ }
178
+
179
+ return panel
180
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Video capture — streams WebM chunks directly to disk via IPC.
3
+ *
4
+ * Flow:
5
+ * 1. Renderer gets media source ID from main (via webContents.getMediaSourceId)
6
+ * 2. Renderer opens getUserMedia stream and starts MediaRecorder
7
+ * 3. Each MediaRecorder chunk is sent to main as ArrayBuffer via IPC
8
+ * 4. Main process appends chunks to a file on disk
9
+ * 5. On stop, main closes the file and returns the path
10
+ *
11
+ * No base64 encoding. No large data through WebSocket.
12
+ */
13
+
14
+ let videoRecorder = null
15
+ let videoRecordingId = null
16
+ let videoStartTime = 0
17
+ let videoMaxDurationTimer = null
18
+ let videoStream = null
19
+
20
+ export function initVideoCapture() {
21
+ if (!window.haltija) return
22
+
23
+ window.haltija.onVideoStart?.(async (data) => {
24
+ if (videoRecorder) {
25
+ window.haltija.videoStartResult({ success: false, error: 'Video recording already in progress' })
26
+ return
27
+ }
28
+
29
+ const { getActiveWebview } = window._tabs
30
+ const webview = getActiveWebview()
31
+ if (!webview || !webview.getWebContentsId) {
32
+ window.haltija.videoStartResult({ success: false, error: 'No active webview for video capture' })
33
+ return
34
+ }
35
+
36
+ const maxDuration = (data.maxDuration || 60) * 1000
37
+
38
+ try {
39
+ const wcId = webview.getWebContentsId()
40
+ const sourceId = await window.haltija.getMediaSourceId(wcId)
41
+ if (!sourceId) {
42
+ window.haltija.videoStartResult({ success: false, error: 'Failed to get media source ID for webview' })
43
+ return
44
+ }
45
+
46
+ // Ask main process to create the output file
47
+ const fileResult = await window.haltija.videoFileCreate()
48
+ if (!fileResult?.success) {
49
+ window.haltija.videoStartResult({ success: false, error: fileResult?.error || 'Failed to create video file' })
50
+ return
51
+ }
52
+
53
+ videoRecordingId = fileResult.recordingId
54
+
55
+ const stream = await navigator.mediaDevices.getUserMedia({
56
+ audio: false,
57
+ video: {
58
+ mandatory: {
59
+ chromeMediaSource: 'tab',
60
+ chromeMediaSourceId: sourceId,
61
+ },
62
+ },
63
+ })
64
+
65
+ videoStream = stream
66
+ videoStartTime = Date.now()
67
+
68
+ const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
69
+ ? 'video/webm;codecs=vp9'
70
+ : 'video/webm'
71
+
72
+ videoRecorder = new MediaRecorder(stream, { mimeType })
73
+
74
+ videoRecorder.ondataavailable = async (e) => {
75
+ if (e.data.size > 0) {
76
+ // Convert Blob to ArrayBuffer and send to main for disk write
77
+ const buffer = await e.data.arrayBuffer()
78
+ window.haltija.videoFileChunk(videoRecordingId, buffer)
79
+ }
80
+ }
81
+
82
+ videoRecorder.onstop = () => {
83
+ stream.getTracks().forEach(t => t.stop())
84
+ videoStream = null
85
+ }
86
+
87
+ // Emit chunks every 500ms for smooth streaming to disk
88
+ videoRecorder.start(500)
89
+
90
+ videoMaxDurationTimer = setTimeout(() => {
91
+ if (videoRecorder?.state === 'recording') {
92
+ console.log('[Video] Auto-stopping after max duration')
93
+ stopRecording()
94
+ }
95
+ }, maxDuration)
96
+
97
+ window.haltija.videoStartResult({ success: true, recordingId: videoRecordingId })
98
+ } catch (err) {
99
+ videoRecordingId = null
100
+ window.haltija.videoStartResult({ success: false, error: `Failed to start video: ${err.message}` })
101
+ }
102
+ })
103
+
104
+ window.haltija.onVideoStop?.(async () => {
105
+ if (!videoRecorder || videoRecorder.state === 'inactive') {
106
+ window.haltija.videoStopResult({ success: false, error: 'No video recording in progress' })
107
+ return
108
+ }
109
+ await stopRecording()
110
+ })
111
+
112
+ window.haltija.onVideoStatus?.(() => {
113
+ const recording = videoRecorder?.state === 'recording'
114
+ window.haltija.videoStatusResult({
115
+ recording,
116
+ recordingId: recording ? videoRecordingId : undefined,
117
+ duration: recording ? (Date.now() - videoStartTime) / 1000 : undefined,
118
+ })
119
+ })
120
+ }
121
+
122
+ async function stopRecording() {
123
+ if (videoMaxDurationTimer) {
124
+ clearTimeout(videoMaxDurationTimer)
125
+ videoMaxDurationTimer = null
126
+ }
127
+
128
+ const duration = (Date.now() - videoStartTime) / 1000
129
+ const id = videoRecordingId
130
+
131
+ // Stop the recorder — this triggers final ondataavailable + onstop
132
+ await new Promise((resolve) => {
133
+ videoRecorder.onstop = () => {
134
+ if (videoStream) {
135
+ videoStream.getTracks().forEach(t => t.stop())
136
+ videoStream = null
137
+ }
138
+ resolve()
139
+ }
140
+ videoRecorder.stop()
141
+ })
142
+
143
+ // Small delay to ensure final chunk IPC completes
144
+ await new Promise(r => setTimeout(r, 100))
145
+
146
+ // Tell main to close the file and get the path
147
+ const result = await window.haltija.videoFileClose(id, duration)
148
+
149
+ videoRecorder = null
150
+ videoRecordingId = null
151
+ videoStartTime = 0
152
+
153
+ window.haltija.videoStopResult(result)
154
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Webview event setup — navigation events, title updates, keyboard shortcuts, etc.
3
+ */
4
+
5
+ import { tabs, activeTabId, el, getServerUrl } from './state.js'
6
+ import { updateNavButtons, checkHaltija } from './status.js'
7
+
8
+ // These are set by renderer.js to avoid circular imports
9
+ let _navigate, _createTab, _activateTab, _closeTab
10
+ export function setTabFunctions({ navigate, createTab, activateTab, closeTab }) {
11
+ _navigate = navigate
12
+ _createTab = createTab
13
+ _activateTab = activateTab
14
+ _closeTab = closeTab
15
+ }
16
+
17
+ export async function injectWidget(webview) {
18
+ const currentUrl = webview.getURL()
19
+ if (!currentUrl || currentUrl === 'about:blank') return
20
+
21
+ const serverUrl = getServerUrl()
22
+ const script = `
23
+ (function() {
24
+ if (document.getElementById('haltija-widget')) return;
25
+ fetch('${serverUrl}/inject.js')
26
+ .then(r => r.text())
27
+ .then(code => eval(code))
28
+ .catch(e => console.error('[Haltija] Injection failed:', e));
29
+ })();
30
+ `
31
+
32
+ try {
33
+ await webview.executeJavaScript(script)
34
+ } catch (err) {
35
+ console.error('[Haltija Desktop] Injection failed:', err)
36
+ }
37
+ }
38
+
39
+ export function setupWebviewEvents(tab) {
40
+ const webview = tab.webview
41
+
42
+ webview.addEventListener('did-start-loading', () => {
43
+ webview.classList.add('loading')
44
+ if (tab.id === activeTabId) {
45
+ el.statusDot.className = 'status-dot connecting'
46
+ }
47
+ })
48
+
49
+ webview.addEventListener('did-stop-loading', () => {
50
+ webview.classList.remove('loading')
51
+ if (tab.id === activeTabId) {
52
+ updateNavButtons()
53
+ checkHaltija()
54
+ }
55
+ })
56
+
57
+ webview.addEventListener('did-navigate', (e) => {
58
+ tab.url = e.url
59
+ if (tab.id === activeTabId && !tab.isTerminal) {
60
+ el.urlInput.value = e.url
61
+ updateNavButtons()
62
+ }
63
+ })
64
+
65
+ webview.addEventListener('did-navigate-in-page', (e) => {
66
+ tab.url = e.url
67
+ if (tab.id === activeTabId && !tab.isTerminal) {
68
+ el.urlInput.value = e.url
69
+ updateNavButtons()
70
+ }
71
+ })
72
+
73
+ webview.addEventListener('did-finish-load', () => {
74
+ if (window.haltija) {
75
+ window.haltija.webviewReady(webview.getWebContentsId())
76
+ }
77
+ })
78
+
79
+ webview.addEventListener('dom-ready', () => {
80
+ const url = webview.getURL()
81
+ if (url.startsWith('blob:') || url.startsWith('data:')) {
82
+ webview.insertCSS(':root { color-scheme: light !important; }')
83
+ }
84
+ })
85
+
86
+ webview.addEventListener('page-title-updated', (e) => {
87
+ tab.title = e.title || 'New Tab'
88
+ tab.element.querySelector('.tab-title').textContent = tab.title
89
+ if (tab.id === activeTabId) {
90
+ document.title = tab.title || 'Haltija'
91
+ }
92
+ })
93
+
94
+ webview.addEventListener('new-window', async (e) => {
95
+ e.preventDefault()
96
+ const { settings } = await import('./state.js')
97
+ if (settings.confirmNewTabs) {
98
+ const { showNewTabDialog } = await import('./settings.js')
99
+ const allowed = await showNewTabDialog(e.url)
100
+ if (allowed) _createTab(e.url)
101
+ } else {
102
+ _createTab(e.url)
103
+ }
104
+ })
105
+
106
+ webview.addEventListener('console-message', (e) => {
107
+ const prefix = e.level === 2 ? '[warn]' : e.level === 3 ? '[error]' : ''
108
+ console.log(`[Tab ${tab.id}${prefix}]`, e.message)
109
+ })
110
+
111
+ webview.addEventListener('dialog', (e) => {
112
+ const dialogType = e.type
113
+ const message = e.messageText || ''
114
+ console.log(`[Tab ${tab.id}] Native dialog intercepted: ${dialogType} - "${message}"`)
115
+
116
+ if (dialogType === 'confirm') {
117
+ e.dialog.accept()
118
+ } else if (dialogType === 'prompt') {
119
+ e.dialog.dismiss()
120
+ } else {
121
+ e.dialog.accept()
122
+ }
123
+ })
124
+
125
+ webview.addEventListener('context-menu', (e) => {
126
+ e.preventDefault()
127
+ const { x, y } = e.params
128
+
129
+ const menu = document.createElement('div')
130
+ menu.className = 'context-menu'
131
+ menu.style.cssText = `
132
+ position: fixed;
133
+ left: ${e.clientX || x}px;
134
+ top: ${e.clientY || y}px;
135
+ background: var(--surface);
136
+ border: 1px solid var(--border);
137
+ border-radius: 6px;
138
+ padding: 4px 0;
139
+ min-width: 150px;
140
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
141
+ z-index: 10000;
142
+ `
143
+
144
+ const items = [
145
+ { label: 'Back', action: () => webview.canGoBack() && webview.goBack(), enabled: webview.canGoBack() },
146
+ { label: 'Forward', action: () => webview.canGoForward() && webview.goForward(), enabled: webview.canGoForward() },
147
+ { label: 'Reload', action: () => webview.reload() },
148
+ { type: 'separator' },
149
+ { label: 'Inspect Element', action: () => webview.inspectElement(x, y) },
150
+ ]
151
+
152
+ items.forEach((item) => {
153
+ if (item.type === 'separator') {
154
+ const sep = document.createElement('div')
155
+ sep.style.cssText = 'height: 1px; background: var(--border); margin: 4px 0;'
156
+ menu.appendChild(sep)
157
+ } else {
158
+ const menuItem = document.createElement('div')
159
+ menuItem.textContent = item.label
160
+ menuItem.style.cssText = `
161
+ padding: 6px 12px;
162
+ cursor: ${item.enabled === false ? 'default' : 'pointer'};
163
+ opacity: ${item.enabled === false ? '0.5' : '1'};
164
+ color: var(--text);
165
+ `
166
+ if (item.enabled !== false) {
167
+ menuItem.addEventListener('mouseenter', () => { menuItem.style.background = 'var(--hover)' })
168
+ menuItem.addEventListener('mouseleave', () => { menuItem.style.background = 'transparent' })
169
+ menuItem.addEventListener('click', () => {
170
+ document.body.removeChild(menu)
171
+ item.action()
172
+ })
173
+ }
174
+ menu.appendChild(menuItem)
175
+ }
176
+ })
177
+
178
+ document.body.appendChild(menu)
179
+
180
+ const closeMenu = (evt) => {
181
+ if (!menu.contains(evt.target)) {
182
+ if (document.body.contains(menu)) document.body.removeChild(menu)
183
+ document.removeEventListener('click', closeMenu)
184
+ }
185
+ }
186
+ setTimeout(() => document.addEventListener('click', closeMenu), 0)
187
+ })
188
+
189
+ webview.addEventListener('before-input-event', (e) => {
190
+ const input = e.input || e
191
+ const { type, key, meta, control } = input
192
+ if (type !== 'keyDown') return
193
+
194
+ if ((meta || control) && key === 'r') {
195
+ e.preventDefault(); e.stopPropagation()
196
+ webview.reload()
197
+ return
198
+ }
199
+ if ((meta || control) && key === 'l') {
200
+ e.preventDefault(); e.stopPropagation()
201
+ el.urlInput.focus(); el.urlInput.select()
202
+ return
203
+ }
204
+ if ((meta || control) && key === 't') {
205
+ e.preventDefault(); e.stopPropagation()
206
+ _createTab()
207
+ return
208
+ }
209
+ if ((meta || control) && key === 'w') {
210
+ e.preventDefault(); e.stopPropagation()
211
+ _closeTab(tab.id)
212
+ return
213
+ }
214
+ if ((meta || control) && key === '[') {
215
+ e.preventDefault(); e.stopPropagation()
216
+ if (webview.canGoBack()) webview.goBack()
217
+ return
218
+ }
219
+ if ((meta || control) && key === ']') {
220
+ e.preventDefault(); e.stopPropagation()
221
+ if (webview.canGoForward()) webview.goForward()
222
+ return
223
+ }
224
+ })
225
+ }