pinokiod 7.4.1 → 7.4.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.
@@ -1,5 +1,6 @@
1
1
  const RELEASE_VERSION = "8.1.2"
2
2
  const CONDA_SPEC = `ffmpeg=${RELEASE_VERSION}`
3
+ const CONDA_CHANNEL_FLAGS = "--override-channels -c conda-forge"
3
4
 
4
5
  class Ffmpeg {
5
6
  description = "Installs FFmpeg for audio and video processing."
@@ -8,25 +9,40 @@ class Ffmpeg {
8
9
  return CONDA_SPEC
9
10
  }
10
11
 
12
+ condaInstallFlags() {
13
+ if (this.kernel.platform === "win32") {
14
+ return `-y --solver=classic ${CONDA_CHANNEL_FLAGS}`
15
+ }
16
+ return `-y ${CONDA_CHANNEL_FLAGS}`
17
+ }
18
+
19
+ solverRepairCommands() {
20
+ if (this.kernel.platform !== "win32") {
21
+ return []
22
+ }
23
+ return [
24
+ `conda install -y --solver=classic ${CONDA_CHANNEL_FLAGS} "conda-libmamba-solver>=25.4.0" libmamba libmambapy libsolv libarchive`
25
+ ]
26
+ }
27
+
11
28
  async install(req, ondata) {
12
29
  await this.kernel.bin.exec({
13
30
  message: [
14
31
  "conda clean -y --all",
15
- `conda install -y -c conda-forge ${this.cmd()}`
32
+ ...this.solverRepairCommands(),
33
+ `conda install ${this.condaInstallFlags()} ${this.cmd()}`
16
34
  ]
17
35
  }, ondata)
18
36
  }
19
37
 
20
38
  async installed() {
21
- return !!(
22
- this.kernel.bin.installed?.conda?.has("ffmpeg") &&
23
- this.kernel.bin.installed?.conda_versions?.ffmpeg === RELEASE_VERSION
24
- )
39
+ return this.kernel.bin.installed.conda.has("ffmpeg") &&
40
+ this.kernel.bin.installed.conda_versions.ffmpeg === RELEASE_VERSION
25
41
  }
26
42
 
27
43
  async uninstall(req, ondata) {
28
44
  await this.kernel.bin.exec({
29
- message: "conda remove ffmpeg",
45
+ message: "conda remove -y ffmpeg",
30
46
  }, ondata)
31
47
  }
32
48
  }
package/kernel/index.js CHANGED
@@ -43,6 +43,7 @@ const AppLauncher = require('./app_launcher')
43
43
  const ReadyState = require('./ready')
44
44
  const Autolaunch = require('./autolaunch')
45
45
  const LaunchRequirements = require('./launch_requirements')
46
+ const LegacyCleanup = require('./legacy_cleanup')
46
47
  const { DownloaderHelper } = require('node-downloader-helper');
47
48
  const { ProxyAgent } = require('proxy-agent');
48
49
  const fakeUa = require('fake-useragent');
@@ -100,6 +101,7 @@ class Kernel {
100
101
  this.launchRequirements = new LaunchRequirements(this)
101
102
  this.autolaunch = new Autolaunch(this)
102
103
  this.autolaunch_status = this.autolaunch.startupStatus()
104
+ this.legacyCleanup = new LegacyCleanup(this)
103
105
  this.sysReady = new Promise((resolve) => {
104
106
  this._resolveSysReady = resolve
105
107
  })
@@ -1192,6 +1194,11 @@ class Kernel {
1192
1194
  await Environment.ensurePinokioCacheDirs(this, {
1193
1195
  throwOnFailure: true
1194
1196
  })
1197
+ await this.legacyCleanup.runOnce((event) => {
1198
+ if (event && typeof event.raw === "string") {
1199
+ console.log(event.raw.trimEnd())
1200
+ }
1201
+ })
1195
1202
 
1196
1203
  // if key.json doesn't exist, create an empty json file
1197
1204
  let ee = await this.exists(this.homedir, "key.json")
@@ -0,0 +1,167 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+
4
+ const LEGACY_FFMPEG_HOOK_NAMES = [
5
+ 'zz_pinokio_ffmpeg.sh',
6
+ 'zz_pinokio_ffmpeg.bat',
7
+ 'zz_pinokio_ffmpeg.ps1',
8
+ ]
9
+
10
+ const TRANSIENT_REMOVE_ERRORS = new Set([
11
+ 'EBUSY',
12
+ 'ENOTEMPTY',
13
+ 'EPERM',
14
+ 'EACCES',
15
+ ])
16
+
17
+ const REMOVE_RETRY_COUNT = 5
18
+ const REMOVE_RETRY_DELAY_MS = 100
19
+
20
+ function delay(ms) {
21
+ return new Promise((resolve) => setTimeout(resolve, ms))
22
+ }
23
+
24
+ async function pathExists(target) {
25
+ return fs.promises.access(target).then(() => true).catch(() => false)
26
+ }
27
+
28
+ function legacyFfmpegRuntimePaths(kernel) {
29
+ if (!kernel || !kernel.bin || typeof kernel.bin.path !== 'function') {
30
+ return []
31
+ }
32
+ return [
33
+ kernel.bin.path('ffmpeg-env'),
34
+ kernel.bin.path('ffmpeg-pkgs'),
35
+ kernel.bin.path('ffmpeg'),
36
+ kernel.bin.path('ffmpeg-tmp'),
37
+ ]
38
+ }
39
+
40
+ function legacyFfmpegHookFiles(kernel) {
41
+ if (!kernel || !kernel.bin || typeof kernel.bin.path !== 'function') {
42
+ return []
43
+ }
44
+ const dirs = [
45
+ kernel.bin.path('miniconda', 'etc', 'conda', 'activate.d'),
46
+ kernel.bin.path('miniconda', 'etc', 'conda', 'deactivate.d'),
47
+ ]
48
+ return dirs.flatMap((dir) => LEGACY_FFMPEG_HOOK_NAMES.map((name) => path.resolve(dir, name)))
49
+ }
50
+
51
+ function manualFfmpegHookCleanupMessage(failures) {
52
+ const files = failures.map((failure) => failure.target)
53
+ return [
54
+ 'Pinokio could not remove old FFmpeg Conda activation hooks.',
55
+ '',
56
+ 'These old hooks can break the base Conda environment by adding the removed ffmpeg-env runtime to PATH.',
57
+ '',
58
+ 'Close all Pinokio windows and any terminals using Pinokio, then delete these files if they exist:',
59
+ ...files.map((file) => `- ${file}`),
60
+ '',
61
+ 'After deleting them, reopen Pinokio.',
62
+ ].join('\n')
63
+ }
64
+
65
+ class LegacyFfmpegCleanupError extends Error {
66
+ constructor(failures) {
67
+ super(manualFfmpegHookCleanupMessage(failures))
68
+ this.name = 'LegacyFfmpegCleanupError'
69
+ this.failures = failures
70
+ }
71
+ }
72
+
73
+ async function hasAnyPath(paths) {
74
+ for (const target of paths) {
75
+ if (await pathExists(target)) {
76
+ return true
77
+ }
78
+ }
79
+ return false
80
+ }
81
+
82
+ async function hasCleanupTargets(kernel) {
83
+ return await hasAnyPath(legacyFfmpegHookFiles(kernel)) ||
84
+ await hasAnyPath(legacyFfmpegRuntimePaths(kernel))
85
+ }
86
+
87
+ function isTransientRemoveError(error) {
88
+ return error && TRANSIENT_REMOVE_ERRORS.has(error.code)
89
+ }
90
+
91
+ async function retryTransient(operation) {
92
+ let lastError
93
+ for (let attempt = 0; attempt <= REMOVE_RETRY_COUNT; attempt += 1) {
94
+ try {
95
+ return await operation()
96
+ } catch (error) {
97
+ if (error && error.code === 'ENOENT') {
98
+ return null
99
+ }
100
+ lastError = error
101
+ if (!isTransientRemoveError(error) || attempt === REMOVE_RETRY_COUNT) {
102
+ break
103
+ }
104
+ await delay(REMOVE_RETRY_DELAY_MS * (attempt + 1))
105
+ }
106
+ }
107
+ throw lastError
108
+ }
109
+
110
+ async function removeLegacyPath(target, ondata) {
111
+ const existed = await pathExists(target)
112
+ if (!existed) {
113
+ return { target, found: false, removed: false }
114
+ }
115
+
116
+ try {
117
+ await retryTransient(() => fs.promises.rm(target, {
118
+ recursive: true,
119
+ force: true,
120
+ maxRetries: REMOVE_RETRY_COUNT,
121
+ retryDelay: REMOVE_RETRY_DELAY_MS,
122
+ }))
123
+ } catch (error) {
124
+ if (ondata) {
125
+ const reason = error && (error.code || error.message) ? error.code || error.message : String(error)
126
+ ondata({ raw: `could not remove legacy path: ${target} (${reason})\r\n` })
127
+ }
128
+ return { target, found: true, removed: false, error }
129
+ }
130
+
131
+ if (ondata) {
132
+ ondata({ raw: `removed legacy path: ${target}\r\n` })
133
+ }
134
+ return { target, found: true, removed: true }
135
+ }
136
+
137
+ async function run(kernel, ondata) {
138
+ const results = []
139
+ const hookFailures = []
140
+
141
+ for (const target of legacyFfmpegHookFiles(kernel)) {
142
+ const result = await removeLegacyPath(target, ondata)
143
+ results.push(result)
144
+ if (result.found && !result.removed) {
145
+ hookFailures.push(result)
146
+ }
147
+ }
148
+
149
+ if (hookFailures.length > 0) {
150
+ throw new LegacyFfmpegCleanupError(hookFailures)
151
+ }
152
+
153
+ for (const target of legacyFfmpegRuntimePaths(kernel)) {
154
+ results.push(await removeLegacyPath(target, ondata))
155
+ }
156
+
157
+ return results
158
+ }
159
+
160
+ module.exports = {
161
+ LegacyFfmpegCleanupError,
162
+ hasCleanupTargets,
163
+ legacyFfmpegHookFiles,
164
+ legacyFfmpegRuntimePaths,
165
+ name: 'ffmpeg',
166
+ run,
167
+ }
@@ -0,0 +1,47 @@
1
+ const path = require('path')
2
+ const ffmpeg = require('./ffmpeg')
3
+
4
+ const CLEANUPS = [
5
+ ffmpeg,
6
+ ]
7
+
8
+ class LegacyCleanup {
9
+ constructor(kernel) {
10
+ this.kernel = kernel
11
+ this.promises = new Map()
12
+ }
13
+
14
+ async runOnce(ondata) {
15
+ const home = this.kernel && this.kernel.homedir ? path.resolve(this.kernel.homedir) : ''
16
+ if (this.promises.has(home)) {
17
+ return this.promises.get(home)
18
+ }
19
+ const promise = this.run(ondata).catch((error) => {
20
+ this.promises.delete(home)
21
+ throw error
22
+ })
23
+ this.promises.set(home, promise)
24
+ return promise
25
+ }
26
+
27
+ async run(ondata) {
28
+ if (!this.kernel || !this.kernel.homedir) {
29
+ return { skipped: true, results: [] }
30
+ }
31
+
32
+ const cleanupResults = []
33
+ for (const cleanup of CLEANUPS) {
34
+ if (await cleanup.hasCleanupTargets(this.kernel)) {
35
+ cleanupResults.push({
36
+ name: cleanup.name || 'legacy',
37
+ results: await cleanup.run(this.kernel, ondata),
38
+ })
39
+ }
40
+ }
41
+
42
+ return { skipped: false, results: cleanupResults }
43
+ }
44
+ }
45
+
46
+ module.exports = LegacyCleanup
47
+ module.exports.ffmpeg = ffmpeg
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "7.4.1",
3
+ "version": "7.4.2",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,73 @@
1
+ const assert = require('node:assert/strict')
2
+ const test = require('node:test')
3
+
4
+ const Ffmpeg = require('../kernel/bin/ffmpeg')
5
+
6
+ function createKernel(platform = 'win32') {
7
+ return {
8
+ platform,
9
+ bin: {
10
+ installed: {
11
+ conda: new Set(),
12
+ conda_versions: {},
13
+ },
14
+ exec: async () => {},
15
+ },
16
+ }
17
+ }
18
+
19
+ function createFfmpeg(kernel) {
20
+ const ffmpeg = new Ffmpeg()
21
+ ffmpeg.kernel = kernel
22
+ return ffmpeg
23
+ }
24
+
25
+ test('FFmpeg bin installs pinned ffmpeg into base Conda on Windows', async () => {
26
+ const calls = []
27
+ const kernel = createKernel('win32')
28
+ kernel.bin.exec = async (payload) => {
29
+ calls.push(payload)
30
+ }
31
+
32
+ await createFfmpeg(kernel).install({}, () => {})
33
+
34
+ assert.equal(calls.length, 1)
35
+ assert.deepEqual(calls[0].message, [
36
+ 'conda clean -y --all',
37
+ 'conda install -y --solver=classic --override-channels -c conda-forge "conda-libmamba-solver>=25.4.0" libmamba libmambapy libsolv libarchive',
38
+ 'conda install -y --solver=classic --override-channels -c conda-forge ffmpeg=8.1.2',
39
+ ])
40
+ assert.equal(calls[0].env, undefined)
41
+ assert.equal(calls[0].message.some((command) => /ffmpeg-env|conda create|-p /.test(command)), false)
42
+ })
43
+
44
+ test('FFmpeg bin installs pinned ffmpeg into base Conda without Windows solver repair elsewhere', async () => {
45
+ const calls = []
46
+ const kernel = createKernel('darwin')
47
+ kernel.bin.exec = async (payload) => {
48
+ calls.push(payload)
49
+ }
50
+
51
+ await createFfmpeg(kernel).install({}, () => {})
52
+
53
+ assert.deepEqual(calls[0].message, [
54
+ 'conda clean -y --all',
55
+ 'conda install -y --override-channels -c conda-forge ffmpeg=8.1.2',
56
+ ])
57
+ })
58
+
59
+ test('FFmpeg installed check requires the base Conda package at the pinned version', async () => {
60
+ const kernel = createKernel('win32')
61
+ const ffmpeg = createFfmpeg(kernel)
62
+
63
+ kernel.bin.installed.conda = new Set(['ffmpeg'])
64
+ kernel.bin.installed.conda_versions = { ffmpeg: '8.1.2' }
65
+ assert.equal(await ffmpeg.installed(), true)
66
+
67
+ kernel.bin.installed.conda_versions = { ffmpeg: '8.1.1' }
68
+ assert.equal(await ffmpeg.installed(), false)
69
+
70
+ kernel.bin.installed.conda = new Set()
71
+ kernel.bin.installed.conda_versions = {}
72
+ assert.equal(await ffmpeg.installed(), false)
73
+ })
@@ -0,0 +1,161 @@
1
+ const assert = require('node:assert/strict')
2
+ const fsSync = require('node:fs')
3
+ const fs = require('node:fs/promises')
4
+ const os = require('node:os')
5
+ const path = require('node:path')
6
+ const test = require('node:test')
7
+
8
+ const LegacyCleanup = require('../kernel/legacy_cleanup')
9
+
10
+ function createKernel(root) {
11
+ return {
12
+ homedir: root,
13
+ bin: {
14
+ path: (...parts) => path.join(root, 'bin', ...parts),
15
+ },
16
+ }
17
+ }
18
+
19
+ async function writeFixtureFile(filepath) {
20
+ await fs.mkdir(path.dirname(filepath), { recursive: true })
21
+ await fs.writeFile(filepath, 'legacy\n')
22
+ }
23
+
24
+ async function exists(filepath) {
25
+ try {
26
+ await fs.access(filepath)
27
+ return true
28
+ } catch (_) {
29
+ return false
30
+ }
31
+ }
32
+
33
+ async function createLegacyFfmpegState(root) {
34
+ const activate = path.join(root, 'bin', 'miniconda', 'etc', 'conda', 'activate.d')
35
+ const deactivate = path.join(root, 'bin', 'miniconda', 'etc', 'conda', 'deactivate.d')
36
+ const hooks = [
37
+ path.join(activate, 'zz_pinokio_ffmpeg.sh'),
38
+ path.join(activate, 'zz_pinokio_ffmpeg.bat'),
39
+ path.join(activate, 'zz_pinokio_ffmpeg.ps1'),
40
+ path.join(deactivate, 'zz_pinokio_ffmpeg.sh'),
41
+ path.join(deactivate, 'zz_pinokio_ffmpeg.bat'),
42
+ path.join(deactivate, 'zz_pinokio_ffmpeg.ps1'),
43
+ ]
44
+ for (const hook of hooks) {
45
+ await writeFixtureFile(hook)
46
+ }
47
+
48
+ const dirs = [
49
+ path.join(root, 'bin', 'ffmpeg-env'),
50
+ path.join(root, 'bin', 'ffmpeg-pkgs'),
51
+ path.join(root, 'bin', 'ffmpeg'),
52
+ path.join(root, 'bin', 'ffmpeg-tmp'),
53
+ ]
54
+ for (const dir of dirs) {
55
+ await fs.mkdir(dir, { recursive: true })
56
+ await fs.writeFile(path.join(dir, 'marker.txt'), 'legacy\n')
57
+ }
58
+
59
+ return { hooks, dirs }
60
+ }
61
+
62
+ test('LegacyCleanup removes stale FFmpeg Conda hooks and old runtime paths once at startup', async () => {
63
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-legacy-cleanup-'))
64
+ const { hooks, dirs } = await createLegacyFfmpegState(root)
65
+ const packets = []
66
+ const cleanup = new LegacyCleanup(createKernel(root))
67
+
68
+ const result = await cleanup.runOnce((packet) => packets.push(packet))
69
+
70
+ assert.equal(result.skipped, false)
71
+ assert.ok(result.results.some((cleanupResult) => {
72
+ return cleanupResult.name === 'ffmpeg' &&
73
+ cleanupResult.results.some((item) => item.found && item.removed)
74
+ }))
75
+ for (const hook of hooks) {
76
+ assert.equal(await exists(hook), false, `${hook} should be removed`)
77
+ }
78
+ for (const dir of dirs) {
79
+ assert.equal(await exists(dir), false, `${dir} should be removed`)
80
+ }
81
+ assert.ok(packets.some((packet) => /removed legacy path:/.test(packet.raw || '')))
82
+ })
83
+
84
+ test('LegacyCleanup runOnce does not repeat work in the same process', async () => {
85
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-legacy-once-'))
86
+ const kernel = createKernel(root)
87
+ const cleanup = new LegacyCleanup(kernel)
88
+ const first = await cleanup.runOnce(() => {})
89
+
90
+ const lateHook = path.join(root, 'bin', 'miniconda', 'etc', 'conda', 'activate.d', 'zz_pinokio_ffmpeg.bat')
91
+ await writeFixtureFile(lateHook)
92
+ const second = await cleanup.runOnce(() => {})
93
+
94
+ assert.strictEqual(second, first)
95
+ assert.equal(await exists(lateHook), true)
96
+ })
97
+
98
+ test('LegacyCleanup tells the user which stale FFmpeg hooks to delete when removal fails', async () => {
99
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-legacy-locked-'))
100
+ await createLegacyFfmpegState(root)
101
+ const kernel = createKernel(root)
102
+ const originalRm = fsSync.promises.rm
103
+ const lockedHook = path.join(root, 'bin', 'miniconda', 'etc', 'conda', 'activate.d', 'zz_pinokio_ffmpeg.bat')
104
+ const packets = []
105
+
106
+ fsSync.promises.rm = async (target, options) => {
107
+ if (target === lockedHook) {
108
+ const error = new Error('locked')
109
+ error.code = 'EPERM'
110
+ throw error
111
+ }
112
+ return originalRm(target, options)
113
+ }
114
+
115
+ try {
116
+ await assert.rejects(
117
+ new LegacyCleanup(kernel).runOnce((packet) => packets.push(packet)),
118
+ (error) => {
119
+ assert.equal(error.name, 'LegacyFfmpegCleanupError')
120
+ assert.match(error.message, /Close all Pinokio windows/)
121
+ assert.match(error.message, /delete these files/)
122
+ assert.match(error.message, /zz_pinokio_ffmpeg\.bat/)
123
+ assert.ok(error.failures.some((failure) => failure.target === lockedHook))
124
+ return true
125
+ }
126
+ )
127
+ assert.ok(packets.some((packet) => /could not remove legacy path:/.test(packet.raw || '')))
128
+ } finally {
129
+ fsSync.promises.rm = originalRm
130
+ }
131
+ })
132
+
133
+ test('LegacyCleanup does not block startup when only an old FFmpeg runtime directory is locked', async () => {
134
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-legacy-runtime-locked-'))
135
+ const runtimeDir = path.join(root, 'bin', 'ffmpeg-env')
136
+ await fs.mkdir(runtimeDir, { recursive: true })
137
+ await fs.writeFile(path.join(runtimeDir, 'marker.txt'), 'legacy\n')
138
+ const kernel = createKernel(root)
139
+ const originalRm = fsSync.promises.rm
140
+ const packets = []
141
+
142
+ fsSync.promises.rm = async (target, options) => {
143
+ if (target === runtimeDir) {
144
+ const error = new Error('locked')
145
+ error.code = 'EPERM'
146
+ throw error
147
+ }
148
+ return originalRm(target, options)
149
+ }
150
+
151
+ try {
152
+ const result = await new LegacyCleanup(kernel).runOnce((packet) => packets.push(packet))
153
+ assert.ok(result.results.some((cleanupResult) => {
154
+ return cleanupResult.name === 'ffmpeg' &&
155
+ cleanupResult.results.some((item) => item.target === runtimeDir && item.removed === false)
156
+ }))
157
+ assert.ok(packets.some((packet) => /could not remove legacy path:/.test(packet.raw || '')))
158
+ } finally {
159
+ fsSync.promises.rm = originalRm
160
+ }
161
+ })