Skip to content

Commit ad15671

Browse files
author
PraneethReddy-github
committed
v1.0.2 — move the vault key into the OS credential store
1 parent 2657f2b commit ad15671

51 files changed

Lines changed: 1519 additions & 454 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.eslintrc.cjs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/* ESLint 8 (eslintrc format) — matches the `eslint . --ext .ts,.tsx` script. */
2+
module.exports = {
3+
root: true,
4+
env: { browser: true, node: true, es2022: true },
5+
parser: '@typescript-eslint/parser',
6+
parserOptions: {
7+
ecmaVersion: 2022,
8+
sourceType: 'module',
9+
ecmaFeatures: { jsx: true }
10+
},
11+
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
12+
extends: [
13+
'eslint:recommended',
14+
'plugin:@typescript-eslint/recommended',
15+
'plugin:react/recommended',
16+
'plugin:react/jsx-runtime',
17+
'plugin:react-hooks/recommended'
18+
],
19+
settings: { react: { version: 'detect' } },
20+
ignorePatterns: ['node_modules/', 'out/', 'dist/', 'dist-electron/', 'resources/', '.eslintrc.cjs'],
21+
rules: {
22+
// The IPC boundary and electron-updater are genuinely untyped; `any` is deliberate there.
23+
'@typescript-eslint/no-explicit-any': 'off',
24+
'@typescript-eslint/no-non-null-assertion': 'off',
25+
// TypeScript already checks props.
26+
'react/prop-types': 'off',
27+
'no-unused-vars': 'off',
28+
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
29+
// `catch { /* best effort */ }` is a deliberate pattern throughout the services.
30+
'no-empty': ['error', { allowEmptyCatch: true }],
31+
'@typescript-eslint/no-unused-expressions': ['error', { allowTernary: true, allowShortCircuit: true }],
32+
// Dependency arrays are hand-tuned in several hooks; surface as guidance, not failure.
33+
'react-hooks/exhaustive-deps': 'warn',
34+
// Terminal code parses OSC/ANSI sequences — control chars in regexes are the point.
35+
'no-control-regex': 'off',
36+
// Lazy `require()` in the main process is deliberate (platform-gated, optional deps).
37+
'@typescript-eslint/no-var-requires': 'off',
38+
// The codebase writes defensive leading semicolons (`;(expr).method()`).
39+
'no-extra-semi': 'off',
40+
// guacamole-lite's types come from shims.d.ts; `@ts-expect-error` would be "unused"
41+
// there and break typecheck. Allow a described `@ts-ignore` instead.
42+
'@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': 'allow-with-description' }]
43+
}
44+
}

README.md

Lines changed: 380 additions & 248 deletions
Large diffs are not rendered by default.

electron-builder.json

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,45 @@
11
{
22
"appId": "dev.ternix.app",
33
"productName": "Ternix",
4-
"directories": { "output": "dist-electron", "buildResources": "resources" },
5-
"files": ["out/**/*", "resources/**/*"],
6-
"asarUnpack": ["**/node_modules/{better-sqlite3,node-pty,serialport,keytar}/**/*"],
4+
"directories": {
5+
"output": "dist-electron",
6+
"buildResources": "resources"
7+
},
8+
"files": [
9+
"out/**/*",
10+
"resources/**/*"
11+
],
12+
"asarUnpack": [
13+
"**/node_modules/{better-sqlite3,node-pty,serialport,keytar}/**/*"
14+
],
715
"npmRebuild": true,
816
"win": {
9-
"target": ["nsis", "portable"],
10-
"icon": "resources/icon.ico"
17+
"target": [
18+
"nsis",
19+
"portable"
20+
],
21+
"icon": "resources/icon.png"
1122
},
1223
"mac": {
13-
"target": ["dmg", "zip"],
14-
"icon": "resources/icon.icns",
24+
"target": [
25+
"dmg",
26+
"zip"
27+
],
28+
"icon": "resources/icon.png",
1529
"hardenedRuntime": true,
1630
"gatekeeperAssess": false,
1731
"entitlements": "resources/entitlements.mac.plist",
1832
"entitlementsInherit": "resources/entitlements.mac.plist"
1933
},
2034
"linux": {
21-
"target": ["AppImage", "deb"],
35+
"target": [
36+
"AppImage",
37+
"deb"
38+
],
2239
"icon": "resources/icons",
23-
"executableArgs": ["--no-sandbox"],
40+
"executableArgs": [
41+
"--no-sandbox"
42+
],
2443
"category": "Utility",
2544
"maintainer": "Ternix"
2645
},

electron/db/migrations/index.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ import type Database from 'better-sqlite3'
33
// Version-based migrations. `user_version` PRAGMA tracks the applied schema version.
44
// Each migration bumps the DB from version N-1 to N. Migration 0 is the base schema
55
// (applied by DatabaseService via SCHEMA_SQL), so migrations here start at 1.
6+
//
7+
// Migrations are forward-only: there is no `down`. An older build opening a newer
8+
// database silently no-ops here (its `pending` list comes out empty) and then runs
9+
// against a schema it was never written for. Reads survive that — they are `SELECT *`
10+
// and ignore unknown columns — but writes name their columns explicitly.
11+
//
12+
// So, if you ever add a schema migration: make it ADDITIVE and give new columns a
13+
// DEFAULT (or allow NULL). A `NOT NULL` column without a default, a renamed column, or
14+
// a dropped table all break any previously-released build a user might downgrade to.
15+
// Keep that rule and downgrades stay safe without any version guard.
616

717
export interface Migration {
818
version: number
@@ -11,12 +21,13 @@ export interface Migration {
1121
}
1222

1323
export const migrations: Migration[] = [
14-
// Example forward-migration scaffold. Add new entries here as the schema evolves.
15-
// {
16-
// version: 1,
17-
// description: 'add color column to snippets',
18-
// up: (db) => db.exec(`ALTER TABLE snippets ADD COLUMN color TEXT`)
19-
// }
24+
{
25+
version: 1,
26+
description: 'repair snippets marked non-global but owned by no session',
27+
// The global checkbox used to persist is_global=0 without ever recording a session_id.
28+
// Such rows are visible from nowhere once scoping is enforced, so adopt them as global.
29+
up: (db) => db.exec(`UPDATE snippets SET is_global = 1 WHERE is_global = 0 AND session_id IS NULL`)
30+
}
2031
]
2132

2233
export function runMigrations(db: Database.Database): void {

electron/db/repo.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DatabaseService } from '../services/DatabaseService'
22
import { CryptoService } from '../services/CryptoService'
3+
import { scopeSnippet } from './snippetScope'
34
import type {
45
Group,
56
Session,
@@ -406,21 +407,26 @@ export const snippetsRepo = {
406407
},
407408
create(data: Partial<Snippet> & { name: string; command: string }): Snippet {
408409
const db = DatabaseService.get()
410+
const scope = scopeSnippet(data.is_global, data.session_id)
409411
const info = db
410412
.prepare(`INSERT INTO snippets (name, description, command, tags, is_global, session_id) VALUES (?, ?, ?, ?, ?, ?)`)
411-
.run(data.name, data.description ?? null, data.command, JSON.stringify(data.tags ?? []), data.is_global === false ? 0 : 1, data.session_id ?? null)
413+
.run(data.name, data.description ?? null, data.command, JSON.stringify(data.tags ?? []), scope.is_global, scope.session_id)
412414
return rowToSnippet(db.prepare(`SELECT * FROM snippets WHERE id = ?`).get(info.lastInsertRowid))
413415
},
414416
update(id: number, data: Partial<Snippet>): Snippet {
415417
const db = DatabaseService.get()
416418
const cur = rowToSnippet(db.prepare(`SELECT * FROM snippets WHERE id = ?`).get(id))
419+
const scope = scopeSnippet(
420+
data.is_global ?? cur.is_global,
421+
data.session_id !== undefined ? data.session_id : cur.session_id
422+
)
417423
db.prepare(`UPDATE snippets SET name=?, description=?, command=?, tags=?, is_global=?, session_id=? WHERE id=?`).run(
418424
data.name ?? cur.name,
419425
data.description !== undefined ? data.description : cur.description,
420426
data.command ?? cur.command,
421427
JSON.stringify(data.tags ?? cur.tags),
422-
(data.is_global ?? cur.is_global) ? 1 : 0,
423-
data.session_id !== undefined ? data.session_id : cur.session_id,
428+
scope.is_global,
429+
scope.session_id,
424430
id
425431
)
426432
return rowToSnippet(db.prepare(`SELECT * FROM snippets WHERE id = ?`).get(id))

electron/db/snippetScope.check.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Self-check for snippet scoping. Run: node --experimental-strip-types electron/db/snippetScope.check.ts
2+
import assert from 'node:assert/strict'
3+
import { scopeSnippet } from './snippetScope.ts'
4+
import { visibleInSession } from '../../src/utils/snippets.ts'
5+
6+
// Global wins regardless of any session id riding along, so toggling the box back to
7+
// global never leaves a stale owner behind.
8+
assert.deepEqual(scopeSnippet(true, null), { is_global: 1, session_id: null })
9+
assert.deepEqual(scopeSnippet(true, 7), { is_global: 1, session_id: null })
10+
assert.deepEqual(scopeSnippet(undefined, null), { is_global: 1, session_id: null })
11+
12+
// Unchecking the box records the owning session.
13+
assert.deepEqual(scopeSnippet(false, 7), { is_global: 0, session_id: 7 })
14+
15+
// An unowned scoped snippet would be visible from nowhere: refuse to write it.
16+
assert.throws(() => scopeSnippet(false, null), /must belong to a session/)
17+
assert.throws(() => scopeSnippet(false, undefined), /must belong to a session/)
18+
19+
const snip = (is_global: boolean, session_id: number | null) =>
20+
({ is_global, session_id }) as Parameters<typeof visibleInSession>[0]
21+
22+
// Global snippets show everywhere, including on local tabs with no session.
23+
assert.equal(visibleInSession(snip(true, null), 7), true)
24+
assert.equal(visibleInSession(snip(true, null), null), true)
25+
26+
// Scoped snippets show only in their own session — this is the bug that was reported.
27+
assert.equal(visibleInSession(snip(false, 7), 7), true)
28+
assert.equal(visibleInSession(snip(false, 7), 8), false)
29+
assert.equal(visibleInSession(snip(false, 7), null), false)
30+
31+
console.log('snippet scoping: all checks passed')

electron/db/snippetScope.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* The two states a snippet may be in. A global snippet belongs to no session; a scoped one
3+
* must name the session that owns it, otherwise it is visible from nowhere.
4+
* Kept dependency-free so both the repo and its self-check can import it.
5+
*/
6+
export function scopeSnippet(is_global: boolean | undefined, session_id: number | null | undefined) {
7+
if (is_global !== false) return { is_global: 1 as const, session_id: null }
8+
if (session_id == null) throw new Error('A session-scoped snippet must belong to a session')
9+
return { is_global: 0 as const, session_id }
10+
}

electron/ipc/sftp.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { handle } from './util'
2-
import { homedir } from 'node:os'
2+
import { homedir, userInfo } from 'node:os'
33
import { readdirSync, mkdirSync, rmSync, renameSync, lstatSync } from 'node:fs'
44
import { join, resolve as resolvePath } from 'node:path'
55
import type { SftpEntry } from '@shared/index'
@@ -11,6 +11,16 @@ function localModePerms(mode: number): string {
1111
return t[(mode >> 6) & 7] + t[(mode >> 3) & 7] + t[mode & 7]
1212
}
1313

14+
/**
15+
* Node gives us a uid, not a name, and there is no passwd lookup in stdlib. The current user
16+
* is the one we can name, and on Windows every file reports uid 0, so name them all.
17+
* ponytail: other uids stay numeric; parse /etc/passwd if that ever matters.
18+
*/
19+
function localOwner(uid: number): string {
20+
const me = userInfo()
21+
return process.platform === 'win32' || uid === me.uid ? me.username : String(uid)
22+
}
23+
1424
function listLocal(dir: string): SftpEntry[] {
1525
// Resolve to absolute path — handles relative inputs and Windows drive roots
1626
const absDir = resolvePath(dir)
@@ -33,7 +43,7 @@ function listLocal(dir: string): SftpEntry[] {
3343
mode: st.mode,
3444
permissions: localModePerms(st.mode),
3545
modified: st.mtimeMs,
36-
owner: String(st.uid ?? ''),
46+
owner: localOwner(st.uid),
3747
group: String(st.gid ?? '')
3848
})
3949
} catch {

electron/ipc/snippets.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export function registerSnippetHandlers(): void {
1919
command: s.command,
2020
description: s.description ?? null,
2121
tags: Array.isArray(s.tags) ? s.tags : [],
22-
is_global: s.is_global !== false
22+
// ponytail: session ids are meaningless across machines, so imports land global.
23+
// Thread a session mapping through here if scoped snippets ever need to survive export.
24+
is_global: true
2325
})
2426
n++
2527
}

electron/ipc/system.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,20 @@ export function registerSystemHandlers(getWindow: () => BrowserWindow | null): v
9797
return autoUpdater
9898
}
9999

100+
/**
101+
* Apply the user's channel choice. The GitHub provider publishes a single `latest.yml`,
102+
* so a separate `beta` channel file would 404 — `allowPrerelease` is what actually
103+
* surfaces pre-releases. Read on every call so switching channels needs no restart.
104+
*/
105+
const applyChannel = (up: any) => {
106+
up.allowPrerelease = (settingsRepo.get('updates.channel') ?? 'stable') === 'beta'
107+
}
108+
100109
handle<{ available: boolean; version?: string }>('updates:check', async () => {
101110
try {
102111
const up = getUpdater()
103112
if (!up) return { available: false }
113+
applyChannel(up)
104114
const result = await up.checkForUpdates()
105115
const latestVersion = result?.updateInfo?.version
106116
const isAvailable = latestVersion && latestVersion !== app.getVersion()
@@ -112,7 +122,9 @@ export function registerSystemHandlers(getWindow: () => BrowserWindow | null): v
112122

113123
handle<void>('updates:download', async () => {
114124
const up = getUpdater()
115-
if (up) await up.downloadUpdate()
125+
if (!up) return
126+
applyChannel(up)
127+
await up.downloadUpdate()
116128
})
117129

118130
on('updates:install', () => {

0 commit comments

Comments
 (0)