runner.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. import Inliner from '@tauri-apps/tauri-inliner'
  2. import toml, { JsonMap } from '@tauri-apps/toml'
  3. import chokidar, { FSWatcher } from 'chokidar'
  4. import { existsSync, readFileSync, writeFileSync } from 'fs-extra'
  5. import { JSDOM } from 'jsdom'
  6. import { debounce, template } from 'lodash'
  7. import path from 'path'
  8. import http from 'http'
  9. import * as net from 'net'
  10. import os from 'os'
  11. import { findClosestOpenPort } from './helpers/net'
  12. import * as entry from './entry'
  13. import { tauriDir, appDir } from './helpers/app-paths'
  14. import logger from './helpers/logger'
  15. import onShutdown from './helpers/on-shutdown'
  16. import { spawn, spawnSync } from './helpers/spawn'
  17. import { exec } from 'child_process'
  18. import { TauriConfig } from './types/config'
  19. import getTauriConfig from './helpers/tauri-config'
  20. import httpProxy from 'http-proxy'
  21. const log = logger('app:tauri', 'green')
  22. const warn = logger('app:tauri (runner)', 'red')
  23. class Runner {
  24. pid: number
  25. tauriWatcher?: FSWatcher
  26. devPath?: string
  27. killPromise?: Function
  28. ranBeforeDevCommand?: boolean
  29. devServer?: net.Server
  30. constructor() {
  31. this.pid = 0
  32. this.tauriWatcher = undefined
  33. onShutdown(() => {
  34. this.stop().catch(e => {
  35. throw e
  36. })
  37. })
  38. }
  39. async run(cfg: TauriConfig): Promise<void> {
  40. let devPath = cfg.build.devPath
  41. if (this.pid) {
  42. if (this.devPath !== devPath) {
  43. await this.stop()
  44. } else {
  45. return
  46. }
  47. }
  48. if (!this.ranBeforeDevCommand && cfg.build.beforeDevCommand) {
  49. this.ranBeforeDevCommand = true // prevent calling it twice on recursive call on our watcher
  50. log('Running `' + cfg.build.beforeDevCommand + '`')
  51. const ls = exec(cfg.build.beforeDevCommand, {
  52. cwd: appDir,
  53. env: process.env
  54. }, error => {
  55. if (error) {
  56. process.exit(1)
  57. }
  58. })
  59. ls.stderr && ls.stderr.pipe(process.stderr)
  60. ls.stdout && ls.stdout.pipe(process.stdout)
  61. }
  62. const tomlContents = this.__getManifest()
  63. this.__whitelistApi(cfg, tomlContents)
  64. this.__rewriteManifest(tomlContents)
  65. entry.generate(tauriDir, cfg)
  66. const runningDevServer = devPath.startsWith('http')
  67. let inlinedAssets: string[] = []
  68. if (runningDevServer) {
  69. const self = this
  70. const devUrl = new URL(devPath)
  71. const proxy = httpProxy.createProxyServer({
  72. ws: true,
  73. target: {
  74. host: devUrl.hostname,
  75. port: devUrl.port
  76. },
  77. selfHandleResponse: true
  78. })
  79. proxy.on('proxyRes', function (proxyRes: http.IncomingMessage, req: http.IncomingMessage, res: http.ServerResponse) {
  80. if (req.url === '/') {
  81. let body: Uint8Array[] = []
  82. proxyRes.on('data', function (chunk: Uint8Array) {
  83. body.push(chunk)
  84. })
  85. proxyRes.on('end', function () {
  86. let bodyStr = body.join('')
  87. const indexDir = os.tmpdir()
  88. writeFileSync(path.join(indexDir, 'index.html'), bodyStr)
  89. self.__parseHtml(cfg, indexDir, false)
  90. .then(({ html }) => {
  91. res.end(html)
  92. }).catch(err => {
  93. res.writeHead(500, JSON.stringify(err))
  94. res.end()
  95. })
  96. })
  97. } else {
  98. if (proxyRes.statusCode) {
  99. res = res.writeHead(proxyRes.statusCode, proxyRes.headers)
  100. }
  101. proxyRes.pipe(res)
  102. }
  103. })
  104. const proxyServer = http.createServer((req, res) => {
  105. delete req.headers['accept-encoding']
  106. proxy.web(req, res)
  107. })
  108. proxyServer.on('upgrade', (req, socket, head) => {
  109. proxy.ws(req, socket, head)
  110. })
  111. const port = await findClosestOpenPort(parseInt(devUrl.port) + 1, devUrl.hostname)
  112. const devServer = proxyServer.listen(port)
  113. this.devServer = devServer
  114. devPath = `${devUrl.protocol}//localhost:${port}`
  115. cfg.build.devPath = devPath
  116. process.env.TAURI_CONFIG = JSON.stringify(cfg)
  117. } else {
  118. inlinedAssets = (await this.__parseHtml(cfg, devPath)).inlinedAssets
  119. }
  120. process.env.TAURI_INLINED_ASSSTS = inlinedAssets.join('|')
  121. this.devPath = devPath
  122. const startDevTauri = async (): Promise<void> => {
  123. return this.__runCargoCommand({
  124. cargoArgs: ['run'],
  125. dev: true,
  126. exitOnPanic: cfg.ctx.exitOnPanic
  127. })
  128. }
  129. // Start watching for tauri app changes
  130. // eslint-disable-next-line security/detect-non-literal-fs-filename
  131. let tauriPaths: string[] = []
  132. // @ts-ignore
  133. if (tomlContents.dependencies.tauri.path) {
  134. // @ts-ignore
  135. const tauriPath = path.resolve(tauriDir, tomlContents.dependencies.tauri.path)
  136. tauriPaths = [
  137. tauriPath,
  138. `${tauriPath}-api`,
  139. `${tauriPath}-updater`,
  140. `${tauriPath}-utils`
  141. ]
  142. }
  143. // eslint-disable-next-line security/detect-non-literal-fs-filename
  144. this.tauriWatcher = chokidar
  145. .watch(
  146. [
  147. path.join(tauriDir, 'src'),
  148. path.join(tauriDir, 'Cargo.toml'),
  149. path.join(tauriDir, 'build.rs'),
  150. path.join(tauriDir, 'tauri.conf.json'),
  151. ...tauriPaths
  152. ].concat(runningDevServer ? [] : [devPath]),
  153. {
  154. ignoreInitial: true,
  155. ignored: runningDevServer ? null : path.join(devPath, 'index.tauri.html')
  156. }
  157. )
  158. .on(
  159. 'change',
  160. debounce((changedPath: string) => {
  161. if (changedPath.startsWith(path.join(tauriDir, 'target'))) {
  162. return
  163. }
  164. (this.pid ? this.__stopCargo() : Promise.resolve())
  165. .then(() => {
  166. const shouldTriggerRun = changedPath.includes('tauri.conf.json') ||
  167. changedPath.startsWith(devPath)
  168. if (shouldTriggerRun) {
  169. this.run(getTauriConfig({ ctx: cfg.ctx })).catch(e => {
  170. throw e
  171. })
  172. } else {
  173. startDevTauri().catch(e => {
  174. throw e
  175. })
  176. }
  177. })
  178. .catch(err => {
  179. warn(err)
  180. process.exit(1)
  181. })
  182. }, 1000)
  183. )
  184. return startDevTauri()
  185. }
  186. async build(cfg: TauriConfig): Promise<void> {
  187. if (cfg.build.beforeBuildCommand) {
  188. const [command, ...args] = cfg.build.beforeBuildCommand.split(' ')
  189. spawnSync(command, args, appDir)
  190. }
  191. const tomlContents = this.__getManifest()
  192. this.__whitelistApi(cfg, tomlContents)
  193. this.__rewriteManifest(tomlContents)
  194. entry.generate(tauriDir, cfg)
  195. const inlinedAssets = (await this.__parseHtml(cfg, cfg.build.distDir)).inlinedAssets
  196. process.env.TAURI_INLINED_ASSSTS = inlinedAssets.join('|')
  197. const features = [
  198. cfg.tauri.embeddedServer.active ? 'embedded-server' : 'no-server'
  199. ]
  200. const buildFn = async (target?: string): Promise<void> =>
  201. this.__runCargoCommand({
  202. cargoArgs: [
  203. cfg.tauri.bundle.active ? 'tauri-bundler' : 'build',
  204. '--features',
  205. ...features,
  206. ...(
  207. cfg.tauri.bundle.active && Array.isArray(cfg.tauri.bundle.targets) && cfg.tauri.bundle.targets.length
  208. ? ['--format'].concat(cfg.tauri.bundle.targets)
  209. : []
  210. )
  211. ]
  212. .concat(cfg.ctx.debug ? [] : ['--release'])
  213. .concat(target ? ['--target', target] : [])
  214. })
  215. if (!cfg.ctx.target) {
  216. // if no target specified,
  217. // build only for the current platform
  218. await buildFn()
  219. } else {
  220. const targets = cfg.ctx.target.split(',')
  221. for (const target of targets) {
  222. await buildFn(target)
  223. }
  224. }
  225. }
  226. async __parseHtml(cfg: TauriConfig, indexDir: string, inlinerEnabled = cfg.tauri.inliner.active): Promise<{ inlinedAssets: string[], html: string }> {
  227. const inlinedAssets: string[] = []
  228. return new Promise((resolve, reject) => {
  229. const indexPath = path.join(indexDir, 'index.html')
  230. if (!existsSync(indexPath)) {
  231. warn(
  232. `Error: cannot find index.html in "${indexDir}". Did you forget to build your web code or update the build.distDir in tauri.conf.json?`
  233. )
  234. reject(new Error('Could not find index.html in dist dir.'))
  235. }
  236. const originalHtml = readFileSync(indexPath).toString()
  237. const rewriteHtml = (html: string, interceptor?: (dom: JSDOM) => void): string => {
  238. const dom = new JSDOM(html)
  239. const document = dom.window.document
  240. if (interceptor !== undefined) {
  241. interceptor(dom)
  242. }
  243. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
  244. if (!((cfg.ctx.dev && cfg.build.devPath.startsWith('http')) || cfg.tauri.embeddedServer.active)) {
  245. const mutationObserverTemplate = require('../templates/mutation-observer').default
  246. const compiledMutationObserver = template(mutationObserverTemplate)
  247. const bodyMutationObserverScript = document.createElement('script')
  248. bodyMutationObserverScript.text = compiledMutationObserver({
  249. target: 'body',
  250. inlinedAssets: JSON.stringify(inlinedAssets)
  251. })
  252. document.body.insertBefore(bodyMutationObserverScript, document.body.firstChild)
  253. const headMutationObserverScript = document.createElement('script')
  254. headMutationObserverScript.text = compiledMutationObserver({
  255. target: 'head',
  256. inlinedAssets: JSON.stringify(inlinedAssets)
  257. })
  258. document.head.insertBefore(headMutationObserverScript, document.head.firstChild)
  259. }
  260. const tauriScript = document.createElement('script')
  261. // @ts-ignore
  262. tauriScript.text = readFileSync(path.join(tauriDir, 'tauri.js'))
  263. document.body.insertBefore(tauriScript, document.body.firstChild)
  264. const csp = cfg.tauri.security.csp
  265. if (csp) {
  266. const cspTag = document.createElement('meta')
  267. cspTag.setAttribute('http-equiv', 'Content-Security-Policy')
  268. cspTag.setAttribute('content', csp)
  269. document.head.appendChild(cspTag)
  270. }
  271. const newHtml = dom.serialize()
  272. writeFileSync(
  273. path.join(indexDir, 'index.tauri.html'),
  274. newHtml
  275. )
  276. return newHtml
  277. }
  278. const domInterceptor = cfg.tauri.embeddedServer.active ? undefined : (dom: JSDOM) => {
  279. const document = dom.window.document
  280. document.querySelectorAll('link').forEach((link: HTMLLinkElement) => {
  281. link.removeAttribute('rel')
  282. link.removeAttribute('as')
  283. })
  284. }
  285. if ((!cfg.ctx.dev && cfg.tauri.embeddedServer.active) || !inlinerEnabled) {
  286. const html = rewriteHtml(originalHtml, domInterceptor)
  287. resolve({ inlinedAssets, html })
  288. } else {
  289. const cwd = process.cwd()
  290. process.chdir( indexDir) // the inliner requires this to properly work
  291. new Inliner({ source: originalHtml }, (err: Error, html: string) => {
  292. process.chdir(cwd) // reset CWD
  293. if (err) {
  294. reject(err)
  295. } else {
  296. const rewrittenHtml = rewriteHtml(html, domInterceptor)
  297. resolve({ inlinedAssets, html: rewrittenHtml })
  298. }
  299. }).on('progress', (event: string) => {
  300. const match = event.match(/([\S\d]+)\.([\S\d]+)/g)
  301. match && inlinedAssets.push(match[0])
  302. })
  303. }
  304. })
  305. }
  306. async stop(): Promise<void> {
  307. return new Promise((resolve, reject) => {
  308. this.devServer && this.devServer.close()
  309. this.tauriWatcher && this.tauriWatcher.close()
  310. this.__stopCargo()
  311. .then(resolve)
  312. .catch(reject)
  313. })
  314. }
  315. async __runCargoCommand({
  316. cargoArgs,
  317. extraArgs,
  318. dev = false,
  319. exitOnPanic = true
  320. }: {
  321. cargoArgs: string[]
  322. extraArgs?: string[]
  323. dev?: boolean
  324. exitOnPanic?: boolean
  325. }): Promise<void> {
  326. return new Promise((resolve, reject) => {
  327. this.pid = spawn(
  328. 'cargo',
  329. extraArgs ? cargoArgs.concat(['--']).concat(extraArgs) : cargoArgs,
  330. tauriDir,
  331. code => {
  332. if (dev && !exitOnPanic && code === 101) {
  333. this.pid = 0
  334. resolve()
  335. return
  336. }
  337. if (code) {
  338. warn()
  339. warn('⚠️ [FAIL] Cargo CLI has failed')
  340. warn()
  341. reject(new Error('Cargo failed with status code ' + code.toString()))
  342. process.exit(1)
  343. } else if (!dev) {
  344. resolve()
  345. }
  346. if (this.killPromise) {
  347. this.killPromise()
  348. this.killPromise = undefined
  349. } else if (dev) {
  350. warn()
  351. warn('Cargo process was killed. Exiting...')
  352. warn()
  353. process.exit(0)
  354. }
  355. resolve()
  356. }
  357. )
  358. if (dev) {
  359. resolve()
  360. }
  361. })
  362. }
  363. async __stopCargo(): Promise<void> {
  364. const pid = this.pid
  365. if (!pid) {
  366. return Promise.resolve()
  367. }
  368. log('Shutting down tauri process...')
  369. this.pid = 0
  370. return new Promise((resolve, reject) => {
  371. this.killPromise = resolve
  372. try {
  373. process.kill(pid)
  374. } catch (e) {
  375. reject(e)
  376. }
  377. })
  378. }
  379. __getManifestPath(): string {
  380. return path.join(tauriDir, 'Cargo.toml')
  381. }
  382. __getManifest(): JsonMap {
  383. const tomlPath = this.__getManifestPath()
  384. const tomlFile = readFileSync(tomlPath).toString()
  385. const tomlContents = toml.parse(tomlFile)
  386. return tomlContents
  387. }
  388. __rewriteManifest(tomlContents: JsonMap): void {
  389. const tomlPath = this.__getManifestPath()
  390. const output = toml.stringify(tomlContents)
  391. writeFileSync(tomlPath, output)
  392. }
  393. __whitelistApi(
  394. cfg: TauriConfig,
  395. tomlContents: { [index: string]: any }
  396. ): void {
  397. const tomlFeatures = []
  398. if (cfg.tauri.whitelist.all) {
  399. tomlFeatures.push('all-api')
  400. } else {
  401. const toKebabCase = (value: string): string => {
  402. return value.replace(/([a-z])([A-Z])/g, '$1-$2')
  403. .replace(/\s+/g, '-')
  404. .toLowerCase()
  405. }
  406. const whitelist = Object.keys(cfg.tauri.whitelist).filter(
  407. w => cfg.tauri.whitelist[String(w)]
  408. )
  409. tomlFeatures.push(...whitelist.map(toKebabCase))
  410. }
  411. if (cfg.tauri.edge.active) {
  412. tomlFeatures.push('edge')
  413. }
  414. tomlContents.dependencies.tauri.features = tomlFeatures
  415. }
  416. }
  417. export default Runner